Step 3. JWT 적용
1. 준비
// build.gradle
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'
}
// application.yml
application:
jwt:
secreat: ${SECREAT:341242FF3617E8D58D7A4F6FEED56FEB8619CF82B9B00829A02E161A4418BFA2}
본 예제에서는 jwt 생성시 HS256 알고리즘을 사용합니다.
HS256 알고리즘을 위해서는 secreat 이 256 bit 이상이어야하는데, 이를 위해 'TEST_KEY' 문자열을 SHA256 으로 해시합니다.
@Configuration
public class JwtConfiguration {
@Value("${application.jwt.secreat}")
private String secreat;
@Bean
public JwtAuthTokenProvider jwtAuthTokenProvider() {
return new JwtAuthTokenProvider(secreat);
}
}
secreat 을 환경 변수에서 가져오고, 다음 step 에서 만들 provider 를 bean 으로 등록합니다.
public enum Role {
ADMIN("ROLE_ADMIN", "관리자 권한"),
MEMBER("ROLE_MEMBER", "회원 권한"),
UNKNOWN("UNKNOWN", "알 수 없는 권한");
private final String code;
private final String description;
}
@Entity
public class Member {
private String role;
public Member(String email, String password) {
this.email = email;
this.password = password;
this.role = Role.MEMBER.getCode();
}
}
Member entity 에 role 을 추가하고, 생성자에 Member 권한을 부여하는 로직을 추가합니다.
예외 처리는 최소화해서 구현했기에 생략합니다. repository 를 참고해주세요.
2. JwtAuthToken & Provider 구현
public class JwtAuthToken implements AuthToken<Claims> {
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@Getter
private final String token;
private final Key secreatKey;
JwtAuthToken(String token, Key secreatKey) {
this.token = token;
this.secreatKey = secreatKey;
}
JwtAuthToken(String email, String role, Date expiredDate, Key secreatKey) {
Map<String, Object> claims = new HashMap<>() {
{
put("email", email);
put("role", role);
}
};
this.token = Jwts.builder()
.setClaims(claims)
.signWith(secreatKey, signatureAlgorithm)
.setExpiration(expiredDate)
.compact();
this.secreatKey = secreatKey;
}
@Override
public boolean validate() {
return getClaims() != null;
}
@Override
public Claims getClaims() {
try {
return Jwts.parserBuilder().setSigningKey(secreatKey).build().parseClaimsJws(token).getBody();
} catch (... exception) {
... 예외 처리
}
return null;
}
}
- token 종류가 늘어날 것을 고려해 AuthToken interface 를 활용합니다.
- JwtAuthToken 은 token string 과 secreat 를 관리하는 객체입니다.
- 신규 jwt 생성시 claim 에 이메일 주소와 권한을 넣습니다.
- claims 의 예외 처리는 구현하지 못했습니다. (마지막에 설명합니다.)
public class JwtAuthTokenProvider implements AuthTokenProvider<JwtAuthToken> {
private static final String AUTHORITIES_KEY = "role";
private final Key secreat;
public JwtAuthTokenProvider(String secreat) {
byte[] keyBytes = Decoders.BASE64.decode(secreat);
this.secreat = Keys.hmacShaKeyFor(keyBytes);
}
@Override
public JwtAuthToken createAuthToken(String email, String role, Date expiredDate) {
return new JwtAuthToken(email, role, expiredDate, secreat);
}
@Override
public JwtAuthToken convertAuthToken(String token) {
return new JwtAuthToken(token, secreat);
}
@Override
public Authentication getAuthentication(JwtAuthToken authToken) {
if (authToken.validate()) {
Claims claims = authToken.getClaims();
Collection<? extends GrantedAuthority> authorities = Collections.singleton(
new SimpleGrantedAuthority(claims.get(AUTHORITIES_KEY).toString()));
User principal = new User(claims.get("email").toString(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
} else {
throw new CustomAuthenticationException(ErrorCode.FAILED_GENERATE_TOKEN);
}
}
}
- token 종류가 늘어날 것을 고려해 AuthTokenProvider interface 를 활용합니다.
- JwtAuthToken 을 만드는 역할과 Authentication 을 가져오는 역할을 합니다.
3. 로그인을 구현합니다
@RestController
public class LoginController {
private final LoginService loginService;
@PostMapping("/api/v1/login")
public String login(@RequestBody @Valid LoginMemberRequest request) {
JwtAuthToken jwtAuthToken = loginService.login(request.getLoginId(), request.getPassword());
return jwtAuthToken.getToken();
}
}
@Service
public class LoginService {
private final static long LOGIN_RETENTION_MINUTES = 300;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtAuthTokenProvider jwtAuthTokenProvider;
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
public JwtAuthToken login(String loginId, String password) {
Member member = memberRepository.findByEmail(loginId)
.orElseThrow(() -> new CustomAuthenticationException(NOT_EXIST_MEMBER));
if (!passwordEncoder.matches(password, member.getPassword())) {
throw new CustomAuthenticationException(NOT_VALID_PASSWORD);
}
// 중요! SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
Role role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.findFirst()
.map(Role::of)
.orElseThrow(() -> new CustomAuthrizationException(NOT_EXIST_AUTHORIZATION));
Date expiredDate = Date.from(
LocalDateTime.now().plusMinutes(LOGIN_RETENTION_MINUTES).atZone(ZoneId.systemDefault()).toInstant());
return jwtAuthTokenProvider.createAuthToken(member.getEmail(), role.getCode(), expiredDate);
}
}
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.map(this::createSpringSecurityUser)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.NOT_EXIST_MEMBER));
}
private User createSpringSecurityUser(Member member) {
List<GrantedAuthority> grantedAuthorities = Collections.singletonList(new SimpleGrantedAuthority(member.getRole()));
return new User(member.getEmail(), member.getPassword(), grantedAuthorities);
}
}
로그인 과정에서 SecurityContextHolder 에 인증 정보를 등록하고, jwt 를 반환합니다.
3-1. SecurityContextHolder 흐름
LoginService.login() 에서는 고작 3줄로 표현되었지만, 이 흐름이 security 에서 제공하는 마법같은 인증 기능입니다.
아래는 그 중 일부를 설명한 것으로 디버그 모드를 통해 흐름을 따라가보시는 것을 추천합니다.
@Service
public class LoginService {
// 1. UsernamePasswordAuthenticationToken 를 가져오기 위해 DaoAuthenticationProvider.retrieveUser() 가 호출됩니다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 4. 인증된 Authentication 를 SecurityContextHolder 에 등록합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider{
// 0. BCrypt 로 정의한 PasswordEncoder 가 적용됩니다.
public DaoAuthenticationProvider() {
setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
// 2. loadUserByUsername() 를 CustomUserDetailsService 로 Override 하였기에 우리가 찾는 회원의 UserDetails 를 가져올 수 있습니다.
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 3. 비밀번호를 비교하는 부분으로 passwordEncoder 가 적용됩니다.
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
@Service
public class CustomUserDetailsService implements UserDetailsService {
// 2-1. UserDetails 을 가져오는 로직을 Override 합니다.
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
... UserDetails 가져오기
}
}
4. 로그인 테스트
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class LoginControllerTest {
@DisplayName("[web] 로그인")
@TestFactory
public Stream<DynamicTest> login() {
String email = "hyune@gmail.com";
String password = "1q2w3e4r*";
return Stream.of(
dynamicTest("[성공] 회원 가입", () -> {
... 회원 가입
}),
dynamicTest("[성공] 로그인", () -> {
... 로그인
})
);
}
}
로그인에 성공하면 정상적인 jwt 가 반환됨을 확인할 수 있습니다.
로그인 정보 가져오기는 다음으로 이어집니다.
'Series > 내가 해본' 카테고리의 다른 글
표준 예외 처리에서 로깅까지 (2) (0) | 2021.07.03 |
---|---|
표준 예외 처리에서 로깅까지 (1) (0) | 2021.07.02 |
Spring Security 로 회원 가입을 구현해보자 (3) (0) | 2021.06.19 |
Spring Security 로 회원 가입을 구현해보자 (1) (0) | 2021.06.19 |
enum 을 조회하는 방법 (0) | 2021.01.13 |