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
로그아웃은 어떻게 구현하는지 -> https://un-lazy-midnight.tistory.com/160
토큰이 만료될 경우 재발급 하는 방법-> https://un-lazy-midnight.tistory.com/170
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
@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-회원가입로그인
'💻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 |