728x90
아래 내용은 https://github.com/beaver84/setting-test 에서 실제 소스를 확인할 수 있습니다.
- JWT(JSON Web Token)는 당사자 간에 정보를 JSON 개체로 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 개방형 표준( RFC 7519 )입니다.
- JWT는 비밀( HMAC 알고리즘 포함) 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있습니다. 공개/개인 키 쌍을 사용하여 토큰에 서명할 때 서명은 개인 키를 보유한 당사자만이 서명한 당사자임을 인증합니다.(세션, 쿠키 기반 인증과 차이)
- 권한 부여 : JWT를 사용하는 가장 일반적인 시나리오입니다. Single Sign On은 오버헤드가 적고 다양한 도메인에서 쉽게 사용할 수 있기 때문에 현재 JWT를 널리 사용하는 기능입니다.
JWT 로그인 로직 순서
- authProvider.class 의 authenticate 메소드 실행(인자값은 new TestAuthenticationToken(loginForm.getEmail(), loginForm.getPassword()) 생성자)
- authenticate 안에 아이디, 패스워드 유효성 검증 후, 유효하다면 Authenticate 객체에 authenticated = true 값을 넣어줌, 또한 기구현된 로직에 따라 권한을 부여
- 또한 redis에도 동일한 값을 저장(토큰의 유효 기간 및 중복 로그인 방지 기능 적용)
- jwtTokenProvider.class 의 generateJwtToken 메소드 실행 -> HS256 암호화 알고리즘애 따라 토큰값을 생성
- 위에서 생성된 토큰 값을 바탕으로 MemberService 로그인 메소드에서 response.addHeader("Authorization", "Bearer " + jwtToken) 로 API요청의 헤더부에 토큰값을 입력
@Component
public class JwtTokenProvider {
private static final String SECRET_KEY = "TEST_APP_API_SERVER";
private static final int LOGIN_SESSION_EXTEND_MINUTES = 120;
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final CacheRepository cacheRepository;
private final Gson gson;
@Autowired
public JwtTokenProvider(MemberJpaRepository memberRepository,
@Qualifier("redisRepository") CacheRepository cacheRepository) {
this.cacheRepository = cacheRepository;
this.gson = new Gson();
}
public String generateJwtToken(Member member) {
LocalDateTime expireDate = LocalDateTimeZoneUtil.getNow().plusMinutes(LOGIN_SESSION_EXTEND_MINUTES);
JwtBuilder builder = Jwts.builder()
.setSubject(member.getEmail())
.setHeader(createHeader())
.setClaims(createClaims(member, expireDate))
.signWith(SignatureAlgorithm.HS256, createSigningKey());
return builder.compact();
}
public boolean isValidToken(String token) {
try {
Claims claims = getClaimsFormToken(token);
Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
return Objects.nonNull(member);
} catch (ExpiredJwtException exception) {
return false;
} catch (JwtException exception) {
return false;
} catch (NullPointerException exception) {
return false;
}
}
public Authentication createAuthenticationFromToken(String token) {
Claims claims = getClaimsFormToken(token); // 토큰 복호화
Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
return new TestAuthenticationToken(member.getEmail(), member.getPassword(), member.getAuthorities());
}
public String getTokenFromHeader(String header) {
return header;
}
private Map<String, Object> createHeader() {
Map<String, Object> header = new HashMap<>();
header.put("typ", "JWT");
header.put("alg", "HS256");
header.put("regDate", System.currentTimeMillis());
return header;
}
private Map<String, Object> createClaims(Member member, LocalDateTime expireDt) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", member.getEmail());
claims.put("claims", gson.toJson(member));
return claims;
}
private Key createSigningKey() {
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
public Claims getClaimsFormToken(String token) {
return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY)).parseClaimsJws(token)
.getBody();
}
public void extendSession(String token) {
Claims claims = getClaimsFormToken(token);
Member account = gson.fromJson(claims.get("claims").toString(), Member.class);
String sessionKey = "";
if (account.getRole() == Role.ADMIN) {
sessionKey = "ADMIN:";
} else {
sessionKey = "USER:";
}
cacheRepository.setValue(sessionKey + account.getEmail(), String.valueOf(account.getId()),
LOGIN_SESSION_EXTEND_MINUTES);
}
}
- generateJwtToken 에서 토큰을 생성하고 웹표준(RFC7519)을 지키기 위해 압축하여 헤더에 보관한다.
- isValidToken 에서는 이상이 없는 토큰인지 확인한 후, 유효하지 않은 토큰인 경우 false를 리턴한다.
- createSigningKey 에서는 Base64로 변환한 후, HMAC SHA256 알고리즘으로 압축하여 페이로드에 넣는다.
- 페이로드 예 :
- TestAuthenticationToken 로 생성자 주입하여 인증을 수행한다.
- extendSession 에서 토큰을 파싱하여 정보를 파악한 후, 기간을 연장시킨다.
728x90
- TestAuthenticationToken.java 로그인 후 토큰 인증을 담당하는 곳
public class TestAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
//...1
public TestAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false);
}
//...2
public TestAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
public Object getCredentials() {
return this.credentials;
}
public Object getPrincipal() {
return this.principal;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated,
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
- 1번 생성자 -> AuthProvider 클래스의 authenticate 메서드 -> 2번 생성자 순으로 인증정보를 가져온다.
- MemberService.java 에서 jwt를 통한 로그인 관련 부분
@Transactional
public Member login(LoginFormDto loginForm, HttpServletRequest request, HttpServletResponse response) {
Member member = memberJpaRepository.findByEmail(loginForm.getEmail());
if(Objects.isNull(member)) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
}
//클라이언트에서 온 암호를 복호화하여 DB에 저장된 암호를 복호화 한것과 비교한다.
if (StringUtils.equals(authEncrypter.decrypt(member.getPassword()), authEncrypter.decrypt(loginForm.getPassword()))) {
//멤버 인증하여 토큰 반환
Authentication authentication = authProvider.authenticate(
new TestAuthenticationToken(loginForm.getEmail(), loginForm.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwtToken = jwtTokenProvider.generateJwtToken(member);
response.addHeader("Authorization", "Bearer " + jwtToken);
//토큰이 있으면 account에 토큰정보를 추가하여 출력
if (StringUtils.isNotEmpty(loginForm.getToken())) {
member.setToken(loginForm.getToken());
}
log.debug(loginForm.getEmail() + " 로 로그인에 성공하였습니다.");
return member;
} else {
throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
- SecurityConfig.java - JWT 토큰으로 유저에게 권한을 인증 또는 인가하는 곳
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private AuthenticationProvider authenticationProvider;
private UserDetailsService userDetailsService;
private JwtTokenProvider jwtTokenProvider;
private AuthenticationEntryPoint authenticationEntryPoint; //토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링
@Autowired
public void setAdminAuthProviderFactory(
@Qualifier("authProvider") AuthenticationProvider authProviderFactory) {
this.authenticationProvider = authProviderFactory;
}
@Autowired
public void setUserDetailsService(@Qualifier("memberService") UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Autowired
public void setAuthenticationEntryPoint() {
this.authenticationEntryPoint = new AuthEntryPoint();
}
@Autowired
public void setTokenUtils(@Qualifier("jwtTokenProvider") JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/statics/**");
web.ignoring().antMatchers("/statics/**/*");
}
@Override
protected void configure(HttpSecurity security) throws Exception {
security.authorizeRequests()
.antMatchers("/api/v1/account/login").permitAll()
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), AnonymousAuthenticationFilter.class)
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider)
.userDetailsService(userDetailsService);
}
- AuthEntryPoint - 토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링
public class AuthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException, ServletException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
ApiExceptionInfo apiException = new ApiExceptionInfo();
apiException.setHttpStatus(HttpStatus.UNAUTHORIZED);
apiException.setMessage("인증되지 않은 사용자입니다.");
apiException.setSuccess(false);
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
OutputStream outputStream = httpServletResponse.getOutputStream();
outputStream.write(new Gson().toJson(apiException).getBytes());
outputStream.flush();
}
}
- AuthProvider - redis에 중간저장 및 유효성 검증
@Component
public class AuthProvider implements AuthenticationProvider {
private static final int LOGIN_SESSION_EXTEND_MINUTES = 120;
private final Logger log = LogManager.getLogger(this.getClass());
private final CacheRepository cacheRepository;
private final AuthEncrypter authEncrypter;
private final MemberJpaRepository memberJpaRepository;
public AuthProvider(CacheRepository cacheRepository,
AuthEncrypter authEncrypter,
MemberJpaRepository memberJpaRepository) {
this.cacheRepository = cacheRepository;
this.authEncrypter = authEncrypter;
this.memberJpaRepository = memberJpaRepository;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Object principal = authentication.getPrincipal();
if (Objects.isNull(principal)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "아이디를 입력해주세요.");
}
Member member = memberJpaRepository.findByEmail(principal.toString());
if (Objects.isNull(member)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "존재하지 않는 아이디입니다.");
}
String decryptInputPassword = authEncrypter.decrypt(authentication.getCredentials().toString());
String decryptSavedPassword = authEncrypter.decrypt(member.getPassword());
if (!StringUtils.equals(decryptInputPassword, decryptSavedPassword)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
}
String sessionKey = "";
if (member.getRole() == Role.ADMIN) {
sessionKey = "ADMIN:";
} else {
sessionKey = "USER:";
}
String cacheKey = sessionKey + member.getEmail();
// 중복로그인 방지 임시코드
if (StringUtils.isNotEmpty(cacheRepository.getValue(cacheKey))) {
throw new ApiException(HttpStatus.CONFLICT, "중복로그인이 감지되었습니다.");
}
cacheRepository.setValue(cacheKey, String.valueOf(member.getId()), LOGIN_SESSION_EXTEND_MINUTES);
return new TestAuthenticationToken(member.getEmail(), member.getPassword(), member.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
- cacheRepository - 입력받은 id와 pw로 토큰을 만들기 전에 redis에 임시로 저장한다. (LOGIN_SESSION_EXTEND_MINUTES를 지정, 중복 로그인 방지)
- JwtAuthenticationFilter - 필터로 토큰에 대한 권한을 체크
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final Gson gson;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
this.gson = new Gson();
}
/**
* 요청에 대한 권한을 filter에서 체크
*
* @param request
* @param response
* @param chain
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String header = httpServletRequest.getHeader("Authorization");
if (StringUtils.isNotEmpty(header)) {
String token = jwtTokenProvider.getTokenFromHeader(header);
setAuthentication(token, request, response);
} else {
Cookie[] cookies = httpServletRequest.getCookies();
if (ArrayUtils.isNotEmpty(cookies)) {
for (Cookie cookie : cookies) {
if (StringUtils.equals(cookie.getName(), "auth")) {
String token = cookie.getValue();
setAuthentication(token, request, response);
break;
}
}
}
}
chain.doFilter(request, response);
}
//토큰이 유효한 경우 SecurityContext에 저장
private void setAuthentication(String token, ServletRequest request, ServletResponse response) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
if (jwtTokenProvider.isValidToken(token)) {
Claims claims = jwtTokenProvider.getClaimsFormToken(token);
Member member = gson.fromJson(claims.get("claims").toString(), Member.class);
String jwtToken = jwtTokenProvider.generateJwtToken(member);
jwtTokenProvider.extendSession(jwtToken);
Authentication authentication = jwtTokenProvider.createAuthenticationFromToken(jwtToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
httpServletResponse.addHeader("Authorization", jwtToken);
} else {
throw new ApiException(HttpStatus.UNAUTHORIZED, "승인되지 않은 요청입니다.");
}
}
}
- 테스트 - http://localhost:8070/api/v1/account/login 으로 로그인 시
참고 : https://jwt.io/
728x90
'Spring > Security' 카테고리의 다른 글
CustomAuthenticationSuccessHandler와 CustomAuthenticationFailureHandler (0) | 2023.02.18 |
---|---|
(SpringSecurity) UserDetailsServices 활용 (0) | 2023.02.14 |