Spring Boot에서 로그인을 구현하자!
처음 백엔드 팀원과 역할을 나눌 때, 도메인 별로 분배하는 게 좋다고 판단이 되었다. 두 명이기 때문에 사이즈가 큰 "회원"과 "이벤트"를 하나씩 맡고 나머지 기능들을 나누게 되었다. 그렇게 나는 회원을 맡으며 인증 파트를 맡게 되었다.
먼저 로그인을 어떤 방식으로 구현할 것인지 결정해야만 했다. 세션 방식으로 간단히 구현할 수 있었지만 이왕이면 JWT를 적용하고 싶었다. 그렇게 Spring Security와 JWT를 적용하기 위한 여정이 시작되었다.
JWT(Json Web Token)는 무엇이고 왜 선택했는가?
JWT는 사용자 인증과 식별을 위한 토큰 기반 인증 방식이다. 이 토큰은 사용자의 권한 정보나 서비스 이용을 위한 정보를 포함하며, JWT를 사용하면 RESTful과 같은 무상태(Stateless) 환경에서도 사용자 데이터를 주고받을 수 있는 방법이 제공된다.
JWT는 Header(헤더), Payload(내용), Signature(서명)로 구성된다. 각 요소는 .으로 구분된다. Signature는 Header, Payload를 Base64 URL-safe Encode 한 이후, Header에 명시된 해시함수를 적용하고, 개인키(Private key)로 서명한 전자서명이 담겨 있기 때문에 Header, Payload가 변조되었는지 확인할 수 있다. 이러한 특징 덕분에 JWT를 신뢰할 수 있는 토큰으로 사용할 수 있다.
Base64 URL-safe Encode
웹으로 전송하기 위해 사용하는 문자가 Base64 형태의 문자이다. 일반적인 Base64 Encode 에서 URL 에서 오류없이 사용하도록 '+', '/' 를 각각 '-', '_' 로 표현한 것이다.
기존 세션(Session)을 활용하는 경우 쿠키 등을 이용하여 사용자를 식별하고 서버의 세션 저장소에 해당 인증 내용을 담지만, JWT와 같은 토큰을 클라이언트에 저장하고 요청할 때 HTTP 헤더에 해당 토큰을 첨부하는 것만으로도 간단하게 데이터를 요청하고 응답을 받아올 수 있다. 사용자가 요청을 했을 때 토큰만 확인하면 되므로 세션 관리가 필요 없어지는 장점이 있다.
로그인 구현해보기
본격적으로 로그인을 구현해본다. 이 글에서는 토큰을 발급하기 위한 설정을 마친 후 로그인 시 정상적으로 토큰을 발급하는 것까지 확인한다. 이후의 기능 구현은 아래 블로그 글을 참고하자.
왜 Refresh 토큰을 구현하는지 -> https://un-lazy-midnight.tistory.com/169
대동덕지도 | 로그인 기능 구현에서 Refresh Token을 선택한 이유
결론부터 말하자면... Access Token의 단점을 보완하려고! Access Token의 Stateless 특성 JWT Access Token은 상태 정보를 서버에 저장하지 않고 클라이언트에게 전달되어 검증되는데, 이로 인해 탈취당한 경
un-lazy-midnight.tistory.com
로그아웃은 어떻게 구현하는지 -> https://un-lazy-midnight.tistory.com/160
대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis)
JWT 적용한 로그인은 구현 했는데 말입니다 ... 로그아웃은 어떻게 하지? JWT를 적용한 로그인 기능은 Access Token(AT), Refresh Token(RT)를 이용하여 구현해냈다. 그런데 로그아웃은.. 어떻게 하지? 클라이
un-lazy-midnight.tistory.com
토큰이 만료될 경우 재발급 하는 방법-> https://un-lazy-midnight.tistory.com/170
대동덕지도 | Spring Security + JWT 토큰 유효성 검증과 재발급 (Access Token, Refresh Token)
지난 글 참고: https://un-lazy-midnight.tistory.com/159 대동덕지도 | Spring Boot에서 Spring Security + JWT 로그인을 구현하자! (Access Token, Refresh Token 발급) Spring Boot에서 로그인을 구현하자! 처음 백엔드 팀원과
un-lazy-midnight.tistory.com
1. build.gradle 에 spring boot security와 jwt 추가, application.yml에 secret 추가
//Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
//JWT
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
jwt:
secret: {임의의 문자열을 Base64로 인코딩한 값}
2. Member Entity와 Repository 만들기
- Role은 Enum 타입으로 생성
- username은 닉네임
- email이 로그인 하는 실질적인 principal이 된다.
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Entity
@Getter
public class Member extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String password;
private String image;
@Enumerated(EnumType.STRING)
private Role role; //USER, ADMIN
}
public enum Role {
USER, ADMIN
}
public interface MemberRepository extends JpaRepository<Member, String> {
Optional<Member> findByEmail(String email);
}
3. Security 설정
- 많은 예시 코드에서 SecurityConfig extends WebSecurityConfigurerAdapter를 사용하였으나 Deprecate 되어 대체가 필요. 그래서 SecurityFilterChain Bean 등록을 통해 해결.
https://un-lazy-midnight.tistory.com/154
[해결] Spring Security WebSecurityConfigurerAdapter Deprecated 대체하기
문제 발생 Spring Security로 로그인 기능 구현 당시 SecurityConfig 클래스를 WebSecurityConfigurerAdapter를 통해 Override하려고 했다. 그러나... 더 이상 사용되지 않는다고 한다... 어디로 가야하죠 아저씨... Dep
un-lazy-midnight.tistory.com
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
private final JwtProvider jwtProvider;
// 비밀번호 암호화
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager Bean 등록
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws
Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 만을 고려하여 기본설정 해제
.csrf().disable()
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣음
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 사용 안함
.and()
.headers()
.frameOptions().sameOrigin();
return http.build();
}
}
- .frameOptions().sameOrigin(); 설정하는 이유
SecurityConfigX-Frame-Options 헤더는 clickjacking attack을 방지하기 위해 사용하는 헤더이다. <frame> <iframe>, <embed> or <object> tag에 페이지 렌더링을 허용할지 말지를 정의 할 수 있다. frameOptions의 기본값은 Deny로 오류 방지를 위해 sameOrigin으로 설정하였다.
- SAMEORIGIN: 같은 origin(서버) 에서는 렌더링을 허용
- DENY: 렌더링을 허용하지 않음
- ALLOW-FROM url: 특정 url에서만 렌더링을 허용
4. Token 생성
JwtProvider
@Slf4j
@Component
@Transactional(readOnly = true)
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
private static Key signingKey;
private static final String AUTHORITIES_KEY = "role";
private static final String EMAIL_KEY = "email";
//access token 유효시간 30분
private final long ACCESS_TOKEN_VALID_TIME = 30 * 60 * 1000L;
//refresh token 유효시간 7일
private final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 7 * 1000L;
private final SecurityUserDetailsService userDetailsService;
private final RedisService redisService;
public JwtProvider(SecurityUserDetailsService userDetailsService, RedisService redisService) {
this.userDetailsService = userDetailsService;
this.redisService = redisService;
}
// 객체 초기화, secret Key를 Base64로 인코딩
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder()
.encodeToString(secretKey.getBytes());
signingKey = Keys.hmacShaKeyFor(secretKey.getBytes());
}
// JWT 토큰 생성
public TokenDto createToken(String email, String authorities) {
Long now = System.currentTimeMillis();
String accessToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS512")
.setExpiration(new Date(now + ACCESS_TOKEN_VALID_TIME))
.setSubject("access-token")
.claim(EMAIL_KEY, email)
.claim(AUTHORITIES_KEY, authorities)
.signWith(signingKey)
.compact();
String refreshToken = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS512")
.setExpiration(new Date(now + REFRESH_TOKEN_VALID_TIME))
.setSubject("refresh-token")
.claim(EMAIL_KEY, email)
.signWith(signingKey)
.compact();
return new TokenDto(accessToken, refreshToken);
}
// 토큰으로부터 정보 추출
public Claims getClaims(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) { // Access Token
return e.getClaims();
}
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
String email = getClaims(token).get(EMAIL_KEY).toString();
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* Refresh 토큰으로부터 클레임을 만들고, 이를 통해 User 객체를 생성하여 Authentication 객체를 반환
* @param refresh_token
* @return
*/
public Authentication getAuthenticationByRefreshToken(String refresh_token) {
String userPrincipal = getClaims(refresh_token).get(EMAIL_KEY).toString();
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(userPrincipal);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public long getTokenExpirationTime(String token) {
return getClaims(token).getExpiration().getTime();
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(signingKey)
.build()
.parseClaimsJws(token);
return true;
} catch (SignatureException e) {
log.error("Invalid JWT signature.");
} catch (MalformedJwtException e) {
log.error("Invalid JWT token.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token.");
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.");
} catch (NullPointerException e) {
log.error("JWT Token is empty.");
}
return false;
}
}
JwtAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Access Token 추출
String accessToken = resolveToken(request);
try { // 정상 토큰인지 검사
if (accessToken != null && jwtProvider.validateToken(accessToken)) {
Authentication authentication = jwtProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Save authentication in SecurityContextHolder.");
}
} catch (InvalidTokenException e) { // 잘못된 토큰일 경우
SecurityContextHolder.clearContext();
} catch (InvalidMemberException e) { // 회원을 찾을 수 없을 경우
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
// HTTP Request 헤더로부터 토큰 추출
public String resolveToken(HttpServletRequest httpServletRequest) {
String bearerToken = httpServletRequest.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
5. 사용자 정의
- 사용자가 시스템의 리소스에 접근하기 위해서는 먼저 인증 관리 필터의 검증을 통과해야함
- 인증 관리 필터가 사용자가 입력한 정보대로 기능을 처리하기 위해서는 사용자 정보가 저장된 UserDetails 객체 필요
- UserDetails 객체에 데이터베이스에서 검색한 사용자 정보를 저장하는 UserDetailsService 객체 필요
-> 인증 관리자는 UserDetailsService 객체를 통해 UserDetails 객체를 획득, UserDetails 객체에서 인증(Authentication)과 인가(Authorization)에 필요한 정보들을 추출하여 사용
UserDetailsImpl
- 유저의 정보를 가져오는 UserDetails 인터페이스를 상속하는 클래스
- Authentication을 담고 있으며 user.getRole().getKey()를 통해 사용자의 권한(Authorities)를 부여해 가져올 수 있다.
- Principal과 Credential로 사용할 필드를 각각 member.email, member.password
public class UserDetailsImpl implements UserDetails {
private final Member member;
public UserDetailsImpl(Member member) {
this.member = member;
}
public Member getUser() {
return member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> "ROLE_" + member.getRole().toString()); // key: ROLE_권한
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
// == 세부 설정 == //
@Override
public boolean isAccountNonExpired() { // 계정의 만료 여부
return true;
}
@Override
public boolean isAccountNonLocked() { // 계정의 잠김 여부
return true;
}
@Override
public boolean isCredentialsNonExpired() { // 비밀번호 만료 여부
return true;
}
@Override
public boolean isEnabled() { // 계정의 활성화 여부
return true;
}
}
UserDetailsServiceImpl
- DB에서 사용자의 정보를 직접 가져오는 인터페이스
- loadUserByUsername(String username)을 오버라이드해 구현
- UserDetails를 구현한 UserDetailsImpl 객체를 리턴해 인증 과정에 사용
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private MemberRepository memberRepository;
@Override
public UserDetailsImpl loadUserByUsername(String email) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(InvalidMemberException::new);
return new UserDetailsImpl(member);
}
}
6. TokenDto
accessToken과 refreshToken을 반환하는 DTO
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenDto {
private String accessToken;
private String refreshToken;
public TokenDto(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
7. AuthController와 AuthService
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthController {
private final AuthService authService;
private final MemberService memberService;
private final SendMailService mailService;
private static final long COOKIE_EXPIRATION = 604800;
@Operation(summary = "로그인")
@PostMapping("/login")
public ResponseEntity<?> login(@Validated @RequestBody LoginReq loginUserRQ) {
// User 등록 및 Refresh Token 저장
TokenDto tokenDto = authService.login(loginUserRQ);
// RT 저장
ResponseCookie responseCookie = ResponseCookie.from("refresh-token", tokenDto.getRefreshToken())
.maxAge(COOKIE_EXPIRATION)
.path("/")
.httpOnly(true)
.sameSite("None")
.secure(true)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, httpCookie.toString())
// AT 저장
.header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenDto.getAccessToken())
.build();
}
}
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtProvider jwtProvider;
private final String SERVER = "Server";
// 로그인: 인증 정보 저장 및 비어 토큰 발급
@Transactional
public TokenDto login(LoginReq loginUserRQ) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUserRQ.getEmail(), loginUserRQ.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject()
.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
return generateToken(SERVER, authentication.getName(), getAuthorities(authentication));
}
// 토큰 발급
@Transactional
public TokenDto generateToken(String provider, String email, String authorities) {
// AT, RT 생성
TokenDto tokenDto = jwtProvider.createToken(email, authorities);
return tokenDto;
}
// 권한 이름 가져오기
public String getAuthorities(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
}
|
8. 로그인 후 accessToken, refreshToken 확인
로컬에서 Postman으로 확인한다. 성공!
참고
https://velog.io/@sysy123/Spring-Boot-Spring-Security-JWT-회원가입로그인
[Spring Boot] Spring Security + JWT 회원가입/로그인 (Token 발급 받기)
Spring Security 와 JWT 를 이용한 회원가입 및 로그인 기능 구현 (Token 발급 받기)
velog.io
[JWT] JWT와 JWT를 사용하는 이유
JWT와 JWT를 사용하는 이유에 대한 글입니다.
velog.io
'💻dev > 🕹️Project' 카테고리의 다른 글
대동덕지도 | Spring Security + JWT 토큰 유효성 검증과 재발급 (Access Token, Refresh Token) (0) | 2023.08.13 |
---|---|
대동덕지도 | 로그인 기능 구현에서 Refresh Token을 선택한 이유 (0) | 2023.08.13 |
대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis) (0) | 2023.08.12 |
대동덕지도 | Spring Boot에서 비밀번호 재설정 링크 이메일 전송 기능 구현하기 (0) | 2023.08.11 |
Toy Project | 깃허브 README에 매일 최신 포스트 자동 업데이트 하기 (0) | 2023.04.06 |