[Spring Security] (9) Spring Security x React로 유저 인증 구현하기-3

Park Yeongseo·2024년 6월 17일
1

Spring Security

목록 보기
9/13
post-thumbnail
  1. 사용자 정보를 담을 Member 엔티티 관련 기본 구현
  2. SecurityConfig 작성
  3. 예제에서 사용할 간단한 프론트 페이지 구현
  4. 이메일 인증 기능 추가
  5. 사용자 인증을 위한 UserDetails
  6. JWT 생성, 검출, 발급, 검증 구현
  7. 액세스 토큰
  8. 리프레시 토큰
  9. 로그인 유지

1. Introduction

4번은 사실 추가 기능이니 나중에 구현하도록 하고, 오늘은 5. UserDetails, UserDetailsService와 6. JWT 관련한 서비스를 담당할 JwtService 인터페이스를 작성하고 구현한다.

2. UserDetailsUserDetailsService

UserDetailsUserDetailsService가 어떤 것들인지는 이 글에서 다뤘다. 지금은 크게 바꿀 것 없이 사용해도 좋을 것 같다.

CustomUserDetails

@Builder  
public class CustomUserDetails implements UserDetails {  
    private final Member member;  
  
    @Override  
    public Collection<? extends GrantedAuthority> getAuthorities() {  
		//나중에 추가 
        return List.of();  
    }  
  
    @Override  
    public String getPassword() {  
        return this.member.getPassword();  
    }  
  
    @Override  
    public String getUsername() {  
        return this.member.getUsername();  
    }  
  
    @Override  
    public boolean isAccountNonExpired() {  
        return UserDetails.super.isAccountNonExpired();  
    }  
  
    @Override  
    public boolean isAccountNonLocked() {  
        return UserDetails.super.isAccountNonLocked();  
    }  
  
    @Override  
    public boolean isCredentialsNonExpired() {  
        return UserDetails.super.isCredentialsNonExpired();  
    }  
  
    @Override  
    public boolean isEnabled() {  
        return UserDetails.super.isEnabled();  
    }  
}

CustomUserDetailsService

@RequiredArgsConstructor  
public class CustomUserDetailsService implements UserDetailsService {  
  
    private final MemberRepository memberRepository;  
  
    @Override  
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {  
        Supplier<UsernameNotFoundException> s = () -> new UsernameNotFoundException("username not found");  
        Member member = memberRepository.findByUsername(username).orElseThrow(s);  
        return User.builder()  
                .username(member.getUsername())  
                .password(member.getPassword())  
                .roles(member.getRole().name())  
                .build();  
    }  
}

달라진 점이 있다면 MemberRepository에 이메일을 통해 등록된 회원을 조회하기 위한 findByUsername()가 추가됐다는 것 밖에 없다.

@Repository  
public interface MemberRepository extends JpaRepository<Member, Long> {  

    Optional<Member> findByUsername(String username);  
    boolean existsByUsername(String username);  

}

3. JwtService 인터페이스와 구현

JwtService에서는 JWT 생성, 검출, 검증, 발급을 담당하게 될 것이다. 일단은 인터페이스부터 작성해보자.

우선은 JWT 생성 및 검증을 위한 jjwt 라이브러리를 추가해주도록 하자.

dependencies {
	//...
	implementation "io.jsonwebtoken:jjwt:0.12.5"
	//...
}

JwtService

public interface JwtService {

    String generateAccessToken(String username);
    String generateRefreshToken();

    Optional<String> extractAccessToken(HttpServletRequest request);
    Optional<String> extractRefreshToken(HttpServletRequest request);
    Optional<String> extractName(String accessToken);
    Jws<Claims> validateToken(String token) throws Exception;

    void setAccessToken(HttpServletResponse response, String accessToken);
    void setRefreshToken(HttpServletResponse response, String refreshToken);

}
  • String generateAccesToken()
    + 사용자 이메일을 subject로 하는 JWT를 만든다.
  • String generateRefreshToken()
    + 수명이 긴 리프레시 토큰에는 user-specific한 정보가 들어가지 않아야 한다.
  • Optional<String> extractAccessToken()
    + 요청의 Authorization 헤더에서 액세스 토큰만을 검출해내기 위해 쓴다
  • Optional<String> extractRefreshToken()
    + 리프레시 토큰은 쿠키에 담겨온다. 쿠키에서 리프레시 토큰을 검출해낸다.
  • Jws<Claims> validateToken(String token)
    + 토큰을 검증하고, 검증된 토큰이라면 클레임들을 반환한다.
    + 지금은 그냥 Exception을 던지게 돼있지만, 잘못된 토큰이거나 만료된 경우를 따로 처리할 수도 있다.
  • void setAccessToken()
    + 액세스 토큰을 Authorization 헤더에 담아 반환한다.
  • void setRefreshToken()
    + 리프레시 토큰을 쿠키에 넣는다.

JwtServiceImpl

@Service  
public class JwtServiceImpl implements JwtService{  
  
    public static final String BEARER = "Bearer ";  
    public static final String AUTHORIZATION_HEADER = "Authorization";  
    public static final String REFRESH_TOKEN_COOKIE_NAME = "Refresh";  
  
    @Value("${jwt.access-token-expiration}")  
    private long accessTokenExpiration;  
  
    @Value("${jwt.refresh-token-expiration}")  
    private long refreshTokenExpiration;  
  
    private final SecretKey secretKey;  
  
    public JwtServiceImpl(@Value("${jwt.secret-key}") String secretKey) {
        this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
    }
  
    @Override    
    public String generateAccessToken(String username) {  
        Date now = new Date();  
        return Jwts.builder()  
                .subject(username)  
                .signWith(secretKey, Jwts.SIG.HS512)  
                .issuedAt(now)  
                .expiration(new Date(now.getTime() + accessTokenExpiration))  
                .compact();  
    }  
  
    @Override    
    public String generateRefreshToken() {  
        Date now = new Date();  
        return Jwts.builder()  
                .claim("sub", UUID.randomUUID().toString())  
                .signWith(secretKey, Jwts.SIG.HS512)  
                .issuedAt(now)  
                .expiration(new Date(now.getTime() + refreshTokenExpiration))  
                .compact();  
    }  
   
    @Override    
    public Optional<String> extractAccessToken(HttpServletRequest request) {  
        return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER))  
                .filter(token -> token.startsWith(BEARER))  
                .map(token -> token.replace(BEARER, ""));  
    }  
  
    @Override    
    public Optional<String> extractRefreshToken(HttpServletRequest request) {  
        return Optional.ofNullable(request.getCookies())  
                .flatMap(cookies -> Arrays.stream(cookies)  
                        .filter(e -> e.getName().equals(REFRESH_TOKEN_COOKIE_NAME))  
                        .findAny())  
                .map(Cookie::getValue);  
    }  
   
    @Override    
    public Optional<String> extractName(String accessToken) {  
        try {  
            Jws<Claims> claims = validateToken(accessToken);  
            return Optional.of(claims.getPayload().getSubject());  
        }  
        catch (Exception e) {  
            return Optional.empty();  
        }  
    }  
  
    @Override    
    public Jws<Claims> validateToken(String token) throws Exception {  
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);  
    }  
  
    @Override    
    public void setAccessToken(HttpServletResponse response, String accessToken) {  
        accessToken = BEARER + accessToken;  
        response.setHeader(AUTHORIZATION_HEADER, accessToken);  
    }  
   
    @Override    
    public void setRefreshToken(HttpServletResponse response, String refreshToken) {  
        ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)  
                .path("/")  
                .secure(true)  
                .sameSite("None")  
                .httpOnly(true)  
                .build();  
        response.setHeader("Set-Cookie", cookie.toString());  
    }  
}

위에서부터 차근차근 알아가보자.

	public static final String BEARER = "Bearer ";  
    public static final String AUTHORIZATION_HEADER = "Authorization";  
    public static final String REFRESH_TOKEN_COOKIE_NAME = "Refresh";  
  
    @Value("${jwt.access-token-expiration}")  
    private long accessTokenExpiration;  
  
    @Value("${jwt.refresh-token-expiration}")  
    private long refreshTokenExpiration;  
  
    private final SecretKey secretKey;

	public JwtServiceImpl(@Value("${jwt.secret-key}") String secretKey) {  
	    this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));  
	}

Authorization 헤더와 Bearer

토큰은 Authorization 헤더에 다음과 같은 형식으로 담긴다.

Authorization: <type> <credentials>

Bearer는 JWT나 OAuth2를 인증에 사용할 때의 타입이다.

액세스 토큰과 리프레시 토큰의 만료 기한

@Value("${...$}")를 이용하면 application.yml에서 해당 값을 가져올 수 있다.

jwt:  
  secret-key: (사용할 키)
  access-token-expiration: 7200000  
  refresh-token-expiration: 86400000

액세스 토큰은 2시간, 리프레시 토큰은 24시간으로 설정해줬다.

SecretKey 와 생성자

SecretKey secretKey는 JWT 서명에 쓰일 키고, 생성자의 String secretKey는 이 키를 만들기 위해 쓰인다. 우리는 여기서 HMAC SHA-512를 쓸 것이고, 따라서 키도 최소 512비트가 되어야 한다. UTF-8의 경우 아스키 코드 0 ~ 127까지는 똑같이 1바이트 = 8비트를 사용하므로 application.ymljwt.secret-key는 (아스키 코드 0 ~ 127의 문자만 사용한다면) 최소 64자가 되어야 한다.

    @Override    
    public String generateAccessToken(String username) {  
        Date now = new Date();  
        return Jwts.builder()  
                .subject(username)  
                .signWith(secretKey, Jwts.SIG.HS512)  
                .issuedAt(now)  
                .expiration(new Date(now.getTime() + accessTokenExpiration))  
                .compact();  
    }  

jjwt 라이브러리를 이용해, 사용자 이름, 서명 알고리즘과 키, 발행 시간, 만료 기한을 넣은 액세스 토큰을 만든다.

리프레시 토큰의 경우도 마찬가지로 만들지만, 사용자의 정보를 담지 않으면서도 unique하게 만들 수 있도록 subject로 UUID로 만든 랜덤 문자열을 넣어줬다.

    @Override    
    public Optional<String> extractAccessToken(HttpServletRequest request) {  
        return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER))  
                .filter(token -> token.startsWith(BEARER))  
                .map(token -> token.replace(BEARER, ""));  
    }  
  
    @Override    
    public Optional<String> extractRefreshToken(HttpServletRequest request) {  
        return Optional.ofNullable(request.getCookies())  
                .flatMap(cookies -> Arrays.stream(cookies)  
                        .filter(e -> e.getName().equals(REFRESH_TOKEN_COOKIE_NAME))  
                        .findAny())  
                .map(Cookie::getValue);  
    }  

Authorization 헤더에서 액세스 토큰만을 추출하는 메서드와, 쿠키에서 리프레시 토큰을 추출하는 메서드다. 크게 설명할 건 없다.

    @Override    
    public Optional<String> extractName(String accessToken) {  
        try {  
            Jws<Claims> claims = validateToken(accessToken);  
            return Optional.of(claims.getPayload().getSubject());  
        }  
        catch (Exception e) {  
            return Optional.empty();  
        }  
    }  
   
    @Override    
    public Jws<Claims> validateToken(String token) throws Exception {  
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);  
    }  

validateToken()은 토큰을 검증하고 클레임들을 반환하는 메서드다. 만약 잘못된 토큰이거나 만료된 토큰인 경우에는 예외가 일어나는데, 지금은 그냥 Exception으로 돼있지만, 예외 종류에 따라 구체적으로 처리할 수도 있다.

extractName()에서는 액세스 토큰을 검증하고, 토큰 페이로드의 subject로 들어있는 사용자 이메일을 추출한다. 지금은 예외가 발생했을 때 따로 처리하지 않고 빈 Optional 객체를 반환하고 있다.

    @Override    
    public void setAccessToken(HttpServletResponse response, String accessToken) {  
        accessToken = BEARER + accessToken;  
        response.setHeader(AUTHORIZATION_HEADER, accessToken);  
    }  
   
    @Override    
    public void setRefreshToken(HttpServletResponse response, String refreshToken) {  
        ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)  
                .path("/")  
                .secure(true)  
                .sameSite("None")  
                .httpOnly(true)  
                .build();  
        response.setHeader("Set-Cookie", cookie.toString());  
    }  

setAccessToken()에서는 액세스 토큰을 응답의 Authorization에 담아줄 것이다. 타입을 명시하기 위해 앞에 "Bearer "도 달아줬다.

  • 하지만 사실 액세스 토큰을 이렇게 헤더에 담아줘야만 할 이유는 없다. 구현에 따라 다를 수 있다.

setRefreshToken()에서는 리프레시 토큰을 Set-Cookie 응답 헤더를 이용해 쿠키에 담도록 한다. 이전 글에서 말한 것처럼, HTTP Only, Secure 설정을 하도록 한다.

0개의 댓글