Member
엔티티 관련 기본 구현SecurityConfig
작성UserDetails
4번은 사실 추가 기능이니 나중에 구현하도록 하고, 오늘은 5. UserDetails
, UserDetailsService
와 6. JWT 관련한 서비스를 담당할 JwtService
인터페이스를 작성하고 구현한다.
UserDetails
와 UserDetailsService
UserDetails
와 UserDetailsService
가 어떤 것들인지는 이 글에서 다뤘다. 지금은 크게 바꿀 것 없이 사용해도 좋을 것 같다.
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);
}
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()
String generateRefreshToken()
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.yml
의 jwt.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
설정을 하도록 한다.
다