- Published on
OAuth2 Login + JWT (2) 구현 준비
- Authors
- Name
- ywj9811
이전에 확인한 JWT에 대한 정보를 토대로 OAuth2 Login과 JWT를 융합하여 로그인을 만들어 보도록 하자.
이전에 OAuth2 에서 사용했던 User를 기반으로 구현할 것이다.
User 클래스
@Entity
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userIdx;
private String username;
private String password;
private String email;
private String role;
private String provider;
private String providerId;
@CreationTimestamp
private Timestamp createDate;
private String refreshToken;
public void userRoleSet() {
this.role = "ROLE_USER";
}
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
기본적인 정보 + OAuth2 를 사용할 것이기 때문에
provider라는 어떤 SocialLogin을 하였는지를 저장하는 컬럼과
ProviderId라는 해당 소셜에서 제공되는 정보를 저장할 컬럼을 추가로 가지도록 하자.
그리고 JWT를 통한 로그인에서 refreshToken을 발급 받고 저장할 것이기 때문에 이 또한 추가로 지니도록 하자.
UserDto 클래스
@AllArgsConstructor
@Data
public class UserDto {
String username;
String password;
String email;
public User dtoToDomain() {
return User.builder()
.username(username)
.email(email)
.password(password)
.build();
}
}
UserRepository 클래스
@Repository
public interface UserRepo extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByRefreshToken(String refreshToken);
Optional<User> findByEmail(String email);
}
UserService 클래스
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepo userRepo;
public User findByUsername(String username) {
Optional<User> user = userRepo.findByUsername(username);
if (user.isEmpty())
return null;
return user.get();
}
public User save(User user) {
user.userRoleSet();
User save = userRepo.save(user);
return save;
}
}
UserController 클래스
@Controller
@Slf4j
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@GetMapping("/user")
@ResponseBody
public String loginFin(@AuthenticationPrincipal PrincipalDetails principalDetails) {
log.info("principalDetails = {}", principalDetails);
return "user";
}
@GetMapping("/manager")
@ResponseBody
public String manager(@AuthenticationPrincipal PrincipalDetails principalDetails) {
log.info("principalDetails = {}", principalDetails);
return "manager";
}
@GetMapping("/admin")
@ResponseBody
public String admin(@AuthenticationPrincipal PrincipalDetails principalDetails) {
log.info("principalDetails = {}", principalDetails);
return "admin";
}
@PostMapping("/join")
public String join(UserDto userDto) {
userDto.setPassword(bCryptPasswordEncoder.encode(userDto.getPassword()));
userService.save(userDto.dtoToDomain());
return "loginForm.html";
}
@GetMapping("/joinForm")
public String getJoinForm() {
return "joinForm.html";
}
@GetMapping("/loginForm")
public String getLoginForm() {
return "loginForm.html";
}
@GetMapping("/SnsLogin")
public String getSnsLoginForm() {
return "SnsLogin.html";
}
}
이렇게 기본적인 User 관련 클래스를 가지고 진행하도록 할 것이다.
JWT 관련 클래스 및 설정 작성
build.gradle 추가
// https://mvnrepository.com/artifact/com.auth0/java-jwt
implementation group: 'com.auth0', name: 'java-jwt', version: '3.19.1'
// jwt 편하게 생성시켜주는 라이브러리 mvnRepository 에서 받아옴
JWT를 사용할 것이기 때문에 위의 오픈 소스 라이브러리를 통해서 편의성을 가져도록 할 것이다.
application.yml 추가
# JWT
jwt:
secretKey: base64로 인코딩된 암호 키 (512비트 이상이 되도록 작성 : 영숫자 조합으로 작성)
access:
expiration: 3600000 #한시간
header: Authorization
refresh:
expiration: 1209600000 #2주일
header: Authorization-refresh
- jwt.secretKey : 서버가 가지고 있는 개인 키
- jwt.access(refresh).expiration : 토큰의 만료시간 설정
- jwt.access(refresh).header : 토큰이 담길 헤더의 이름(key) 설정
JwtService (JWT 로직 관련 클래스)
로직이 매우 길기 때문에 전체 코드 이후에 나눠서 설명하도록 할 것이다.
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
/**
* JWT의 Subject와 Claim으로 username 사용 -> 클레임의 name을 "username"으로 설정
* JWT의 헤더에 들어오는 값 : 'Authorization(Key) = Bearer {토큰} (Value)' 형식
* 토큰은 자동으로 Bearer + 값 이렇게 생긴다.
*/
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String USERNAME_CLAIM = "username";
private static final String BEARER = "Bearer ";
private final UserRepo userRepository;
/**
* AccessToken 생성 메소드
*/
public String createAccessToken(String username) {
Date now = new Date();
return JWT.create() // JWT 토큰을 생성하는 빌더 반환
.withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) // 토큰 만료 시간 설정
.withIssuedAt(new Date(now.getTime()))
//클레임으로는 저희는 username 하나만 사용합니다.
//추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다.
//추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다
.withClaim(USERNAME_CLAIM, username)
.sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application.yml에서 지정한 secret 키로 암호화
}
/**
* RefreshToken 생성
* RefreshToken은 Claim에 username도 넣지 않으므로 withClaim() X
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.withIssuedAt(new Date(now.getTime()))
.sign(Algorithm.HMAC512(secretKey));
}
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, "Bearer " + accessToken);
setRefreshTokenHeader(response, "Bearer " + refreshToken);
response.sendRedirect("/"); //"/"로 리다이렉트
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
/**
* 헤더에서 RefreshToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* AccessToken에서 username 추출
* 추출 전에 JWT.require()로 검증기 생성
* verify로 AceessToken 검증 후
* 유효하다면 getClaim()으로 username 추출
* 유효하지 않다면 빈 Optional 객체 반환
*/
public Optional<String> extractUsername(String accessToken) {
try {
// 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build() // 반환된 빌더로 JWT verifier 생성
.verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생
.getClaim(USERNAME_CLAIM) // claim(username) 가져오기
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
/**
* AccessToken 헤더 설정
*/
public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
}
/**
* RefreshToken 헤더 설정
*/
public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
} //위 두개는 위에서 사용중인 메속드임
/**
* RefreshToken DB 저장(업데이트)
*/
public void updateRefreshToken(String username, String refreshToken) {
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isEmpty()) {
new Exception("일치하는 회원이 없습니다.");
}
log.info("RefreshToken 업데이트");
User user = byUsername.get();
user.updateRefreshToken(refreshToken);
userRepository.saveAndFlush(user);
}
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (TokenExpiredException e) {
log.error("토큰 기한이 만료되었습니다 {}", e.getMessage());
throw new JwtException("토큰 기한이 만료되었습니다");
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다. {}", e.getMessage());
throw new JwtException("JWT 토큰이 잘못되었습니다.");
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
throw new JwtException("JWT 예외 발생");
}
}
}
- PART1
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
→ @Value
를 사용하여 각 필드들에 application.yml
의 프로퍼티를 주입하도록 했다.
- PART2
public String createAccessToken(String username) {
Date now = new Date();
return JWT.create() // JWT 토큰을 생성하는 빌더 반환
.withSubject(ACCESS_TOKEN_SUBJECT) // JWT의 Subject 지정 -> AccessToken이므로 AccessToken
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) // 토큰 만료 시간 설정
.withIssuedAt(new Date(now.getTime()))
//클레임으로는 저희는 username 하나만 사용합니다.
//추가적으로 식별자나, 이름 등의 정보를 더 추가하셔도 됩니다.
//추가하실 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정해주시면 됩니다
.withClaim(USERNAME_CLAIM, username)
.sign(Algorithm.HMAC512(secretKey)); // HMAC512 알고리즘 사용, application.yml에서 지정한 secret 키로 암호화
}
/**
* RefreshToken 생성
* RefreshToken은 Claim에 username도 넣지 않으므로 withClaim() X
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.withIssuedAt(new Date(now.getTime()))
.sign(Algorithm.HMAC512(secretKey));
}
→ createAccessToken
: AccessToken 생성 메소드
JWT.create()
를 통해서 JWT 토큰을 생성
이때 .withSubject()
로 JWT Subject를 지정함 (Authorization 과 같은 이름이 들어갈 것이다.)
.withExpiresAt()
과 withIssuedAt()
으로 만료 시간과 발행 시간을 설정한다.
→ 여기까지 Payload에 들어가는 제공 claim이다.
이외에 .withClaim()
을 통해서 사용자 지정 claim을 작성할 수 있다.
.sign()
에는 서버의 개인 키를 암호화 알고리즘으로 암호화 하여 넣어주면 JWT 토큰이 암호화 되어 생성되게 된다.
→ createRefreshToken
: RefreshToken 생성 메소드
위와 마찬가지로 생성하는 것으로 불필요한 부분은 제외했다.
PART3
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken){
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
response.setHeader(refreshHeader, refreshToken);
response.sendRedirect("/"); //"/"로 리다이렉트
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
위의 메소드들은 이름 그대로 AccessToken 혹은 AccessToken + RefreshToken을 헤더에 담아서 반환하는 메소드들이다.
PART4
/**
* 헤더에서 RefreshToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 AccessToken 추출
* 토큰 형식 : Bearer XXX에서 Bearer를 제외하고 순수 토큰만 가져오기 위해서
* 헤더를 가져온 후 "Bearer"를 삭제(""로 replace)
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* AccessToken에서 username 추출
* 추출 전에 JWT.require()로 검증기 생성
* verify로 AceessToken 검증 후
* 유효하다면 getClaim()으로 username 추출
* 유효하지 않다면 빈 Optional 객체 반환
*/
public Optional<String> extractUsername(String accessToken) {
try {
// 토큰 유효성 검사하는 데에 사용할 알고리즘이 있는 JWT verifier builder 반환
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build() // 반환된 빌더로 JWT verifier 생성
.verify(accessToken) // accessToken을 검증하고 유효하지 않다면 예외 발생
.getClaim(USERNAME_CLAIM) // claim(username) 가져오기
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
위의 메소드들은
헤더에서 토큰을 분리해서 가져오거나
토큰에서 username을 얻어내는 메소드이다.
PART5
/**
* RefreshToken DB 저장(업데이트)
*/
public void updateRefreshToken(String email, String refreshToken) {
Optional<User> byUsername = userRepository.findByUsername(username);
if (byUsername.isEmpty()) {
new Exception("일치하는 회원이 없습니다.");
}
log.info("RefreshToken 업데이트");
User user = byUsername.get();
user.updateRefreshToken(refreshToken);
userRepository.saveAndFlush(user);
}
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (TokenExpiredException e) {
log.error("토큰 기한이 만료되었습니다 {}", e.getMessage());
throw new JwtException("토큰 기한이 만료되었습니다");
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 잘못되었습니다. {}", e.getMessage());
throw new JwtException("JWT 토큰이 잘못되었습니다.");
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
throw new JwtException("JWT 예외 발생");
}
}
→ updateRefreshToken()
: DB의 RefreshToken을 업데이트 하는 메소드
→ isTokenValid(String token)
: 토큰의 유효성 검사
다음에는 JWT의 인증 로직과 그에 대한 인증 필터를 알아보도록 할 것이다.