+) 2023.08.18 기준으로
지금 게시글에서 5개월 후에 구현한 JWT 는 아래 링크에서 확인 가능하다.
좀 더 퀄리티있게(?) 바꼈다. 참고~
JWT ) 프론트 서버와 통신하는데에 사용할 JWT 인증을 구현해보자
태초에 첫 토큰 로그인 구현이 있었다. https://hyuil.tistory.com/188 Spring Security ) JWT 토큰 로그인 구현을 해보았다 이것은 드디어 토큰 로그인을 완성한 나를 위한 박수 박수.... 그리고 정리와 저장용.
hyuil.tistory.com
이것은 드디어 토큰 로그인을 완성한 나를 위한 박수 박수....
그리고 정리와 저장용...
일단 난
1. Access 토큰은 프론트(로컬스토리지)에 저장
2. Refresh 토큰은 서버(DB)에 저장 했고
3. 권한은 따로 안 주고 역할만 지정해줬다 - USER, ADMIN, HOLIDAY
4. UserDetails 를 굳이 따로 구현해주었다(이유 있음)
그리고 엔티티를 제외한 시큐리티 관련 클래스 구성은
요런 식으로 해주었다 ^0^/
이건 ROLE (역할) DB 컬럼
이건 MEMBER (유저) DB 인데, 역할 PK를 가지고 있도록 설정했다
시큐리티 기본 설정인
SecurityConfig
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final MemberRepository memberRepository;
private final JwtTokenParser jwtTokenParser;
private final JwtTokenProvider jwtTokenProvider;
private final JwtTokenRepository jwtTokenRepository;
public SecurityConfig(MemberRepository memberRepository, JwtTokenParser jwtTokenParser, JwtTokenProvider jwtTokenProvider, JwtTokenRepository jwtTokenRepository) {
this.memberRepository = memberRepository;
this.jwtTokenParser = jwtTokenParser;
this.jwtTokenProvider = jwtTokenProvider;
this.jwtTokenRepository = jwtTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf()
.disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.mvcMatchers("/fanLetter")
.hasRole("USER")
.anyRequest()
.permitAll()
.and()
.addFilterBefore(new JwtFilter(jwtTokenParser, jwtTokenProvider, userDetailsService()), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(new JwtTokenSetFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증이 실패했을 경우
.accessDeniedHandler(new CustomAccessDeniedHandler()) // 권한이 없을 경우
;
}
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.mvcMatchers("/static/**", "/favicon.ico");
}
@Bean
public UserDetailsService userDetailsService() {
return new CustomUserDetailsService(memberRepository);
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
JWT 토큰을 쓰는 이유가 세션을 쓰지 않기 위해서(또는 세션을 못쓰는 앱의 경우)인 것은
블로그에 몇 백개는 써있다
서버에 부담을 줄이고 확장성을 위해서라는데...부담을 줄인다? 글쎄...(는 이따가)
.authorizeRequests()
.mvcMatchers("/fanLetter")
.hasRole("USER")
.anyRequest()
.permitAll()
인증, 또는 권한(역할)이 필요한 경로를 설정해준다
보통 다른 블로그에는
.antMatchers() 를 사용하는 경우가 많은데
.mvcMatchers() 는 @Controller 와 경로를 매칭하는 방식이 동일하기 때문에 ( "/mvc" 랑 "/mvc/" 둘 다 같은 주소로 취급)
.mvcMatchers()를 사용해주는 것이 보안에 더 유리하다!
.hasRole() 외에도 .hasAuthority() 도 있는데 둘은 좀 다르다
hasAuth- 는 권한이 뭐야? 고
hasRole 은 역할이 뭐야? 다
권한과 역할은 엄연히 다른데
예를 들어,
나는 유저 역할이지만 읽기 권한만 있을 수 있다.
다른 친구는 나와 같은 유저 역할이지만 읽기 쓰기 권한을 동시에 가지고 있다
뭐 다른 복잡한 서비스에서는 역할과 권한을 각각 다르게 둘 수 있겠지만
나는 가벼운 서비스를 만드는 것이어서
"역할"만 두었다.
역할에는 앞에 ROLE_ 을 붙여야만 스프링 시큐리티가 이게 역할이라고 알아들으니까
역할을 뜻하는 DB에는 ROLE_ 을 붙여주자!
참고로 나의 ROLE 엔티티는 이렇게 되어있고
@Entity
@Getter
public class Role {
@Id @GeneratedValue
private Long id;
@Enumerated(EnumType.STRING)
private Name name;
protected Role() {}
public Role(MemberJoinDto memberJoinDto) {
this.name = memberJoinDto.getRoleName();
}
}
역할 이름인 NAME enum은 이런 식으로 되어있다
(of는 문자열이 들어왔을 때 해당 enum을 반환하는 메서드다)
public enum Name {
ROLE_HOLIDAY, ROLE_ADMIN, ROLE_USER;
public static Name of(String name) {
if (name.equals(ROLE_USER)) {
return ROLE_USER;
}
if (name.equals(ROLE_ADMIN)) {
return ROLE_ADMIN;
}
if (name.equals(ROLE_HOLIDAY)) {
return ROLE_HOLIDAY;
}
return null;
}
}
나는 테스트를 위해 임시로
/fanLetter 경로에 user 권한만 들어올 수 있도록 만들었으며
다른 경로는 인증 없이 들어올 수 있도록
.anyRequest().permitAll() 해주었다
.and()
.addFilterBefore(new JwtFilter(jwtTokenParser, jwtTokenProvider, userDetailsService()), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(new JwtTokenSetFilter(), UsernamePasswordAuthenticationFilter.class)
제일 중요한 필터와 핸들러들인데
JwtFilter 는 토큰을 검증하고 해당 토큰이 정상일 경우
Authentication 을 만들어 스프링컨텍스트에 넣어준다(인증해줌)
JwtTokenSetFilter 는 자동으로 계속 헤더에 토큰을 넣어주도록 필터로 만들었다
토큰을 검증한 후, 새 토큰으로 넣어줘야하니 JwtFilter 뒤에 동작해야하므로
JwtFilter -> UsernamePasswordAuthenticationFilter -> JwtTokenSetFilter 순으로 동작하도록 addFilter- 를 사용했다
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증이 실패했을 경우
.accessDeniedHandler(new CustomAccessDeniedHandler()) // 권한이 없을 경우
인증 실패, 권한 없을 경우에 대비한 exception 핸들러를 만들었다
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.mvcMatchers("/static/**", "/favicon.ico");
}
정적 파일은 인증 필터를 거치지 말아줘잉~
JwtFilter
public class JwtFilter extends OncePerRequestFilter {
private final JwtTokenParser jwtTokenParser;
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
public JwtFilter(JwtTokenParser jwtTokenParser, JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) {
this.jwtTokenParser = jwtTokenParser;
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenParser.getTokenHeader(request);
if (token == null) {
String tokenUrl = jwtTokenParser.getTokenUrl(request);
if (tokenUrl == null) {
System.out.println("토큰이 없어요");
filterChain.doFilter(request, response);
return;
}
// url 파라미터에 토큰이 있을 경우
token = tokenUrl;
}
boolean tokenValid = jwtTokenParser.validToken(token, false);
if (!tokenValid) {
Token refreshToken = jwtTokenParser.getRefreshToken(token);
if (refreshToken == null) {
throw new NullPointerException("리프레쉬 토큰이 없음");
}
boolean refreshValid = jwtTokenParser.validToken(refreshToken.getToken(), true);
if (!refreshValid) {
System.out.println("리프레쉬 토큰 인증 실패");
filterChain.doFilter(request, response);
return;
}
}
// 토큰s 재발급 후, 저장~
String memberId = jwtTokenParser.tokenInMemberId(token);
String refreshT = jwtTokenProvider.createRefreshToken();
String accessT = jwtTokenProvider.reCreateJwtToken(token);
// 기존 리프레쉬 토큰이랑 바꿔치기 or 새로 insert
Token refreshToken = jwtTokenProvider.saveRefreshInDB(new Token(memberId, refreshT));
// 인증 완료!
Authentication authentication = extractAuthentication(memberId, accessT);
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("인증 완료!!");
filterChain.doFilter(request, response);
}
// authentication 생성
private Authentication extractAuthentication(String memberId, String accessToken) {
try {
Collection<GrantedAuthority> authorities = jwtTokenParser.getAuthorities(accessToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(memberId);
return new UsernamePasswordAuthenticationToken(userDetails, accessToken, authorities);
} catch (JwtException | IllegalArgumentException | NullPointerException exception) {
throw new BadCredentialsException(exception.getMessage());
}
}
}
해당 필터는
헤더 또는 url 파라미터로 들어온 토큰을 가져와 검증하고,
정상 토큰일 경우 Authentication을 만들어 스프링컨텍스트홀더에 넣어주도록 만드는 필터다
String token = jwtTokenParser.getTokenHeader(request);
if (token == null) {
String tokenUrl = jwtTokenParser.getTokenUrl(request);
if (tokenUrl == null) {
System.out.println("토큰이 없어요");
filterChain.doFilter(request, response);
return;
}
// url 파라미터에 토큰이 있을 경우
token = tokenUrl;
}
먼저 헤더에서 토큰을 가져온다.
그런데, 내 서비스의 경우엔 헤더에 토큰이 없는 경우도 있다.
인증이 필요한 페이지로 갔는데 로그인이 안 돼있을 경우 로그인 폼으로 리다이렉트 되도록 만들었는데
로그인이 성공했을 경우 프론트단에서 window.location.href 를 이용해 다시 전에 있던 페이지로 가도록 만들었으므로
이 경우, 헤더에 토큰을 넣기 어렵기 때문이다.
그래서 나는 어떤 방법을 썼냐면
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String redirectURI = request.getRequestURI();
response.sendRedirect("/loginForm?redirectUrl="+redirectURI);
}
}
인증이 필요한 페이지로 갔는데 인증이 안 된 클라이언트일 경우 동작하는
AuthenticationEntryPoint 에 리퀘스트URI 를 저장하고
그 뒤에 redirectUrl 이라고 파라미터를 넣어서 리퀘스트URI를 넣어주었다.
이렇게 되면
localhost:8080/fanLetter 페이지에 들어간 비인가클라이언트의 경우
저런 URL을 통해 loginForm으로 redirect 되게 된다
그러면 redirectUrl 파라미터가 있는 loginForm은 리다이렉트 된 주소임을 알 수 있고
해당 인증이 성공할 경우 로컬스토리지(프론트)에 있는 토큰을 url파라미터로 넘겨서,
서버에서 파라미터를 확인 후 해당 토큰을 헤더에 넣어줄 수 있다
그러니 다시 돌아오면
String token = jwtTokenParser.getTokenHeader(request);
if (token == null) {
String tokenUrl = jwtTokenParser.getTokenUrl(request);
if (tokenUrl == null) {
System.out.println("토큰이 없어요");
filterChain.doFilter(request, response);
return;
}
// url 파라미터에 토큰이 있을 경우
token = tokenUrl;
}
1. 헤더에서 토큰 확인
2. 없으면 혹시 url파라미터에 있나 확인
- 둘 다 없으면 해당 필터 종료
2-1. url에 존재한다면 token 저장
boolean tokenValid = jwtTokenParser.validToken(token, false);
if (!tokenValid) {
Token refreshToken = jwtTokenParser.getRefreshToken(token);
if (refreshToken == null) {
throw new NullPointerException("리프레쉬 토큰이 없음");
}
boolean refreshValid = jwtTokenParser.validToken(refreshToken.getToken(), true);
if (!refreshValid) {
System.out.println("리프레쉬 토큰 인증 실패");
filterChain.doFilter(request, response);
return;
}
}
이제 토큰이 유효 토큰인지 검사한다
근데 토큰이 유효하지 않을 경우, 리프레쉬 토큰을 확인하고
리프레쉬 토큰이 없다면 NullPointer(이건 수정해야함...)
리프레쉬 토큰이 있다면 해당 토큰이 유효 토큰인지 확인한다
둘 다 인증이 실패하면 그냥 필터를 즉시 종료한다
// 토큰s 재발급 후, 저장~
String memberId = jwtTokenParser.tokenInMemberId(token);
String refreshT = jwtTokenProvider.createRefreshToken();
String accessT = jwtTokenProvider.reCreateJwtToken(token);
// 기존 리프레쉬 토큰이랑 바꿔치기 or 새로 insert
Token refreshToken = jwtTokenProvider.saveRefreshInDB(new Token(memberId, refreshT));
// 인증 완료!
Authentication authentication = extractAuthentication(memberId, accessT);
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("인증 완료!!");
filterChain.doFilter(request, response);
하나라도 유효 토큰일 경우
기존 토큰에 있는 정보를 이용해 액세스 / 리프레쉬 토큰을 각각 새로 만들고
기존 리프레쉬 토큰은 DB에서 업데이트 시키고
액세스 토큰을 이용해 Authentication을 만들어서
SecurityContextHolder에 Authentication을 넣어준다!
그러면 인증이 완료가 됐다 :)
private Authentication extractAuthentication(String memberId, String accessToken) {
try {
Collection<GrantedAuthority> authorities = jwtTokenParser.getAuthorities(accessToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(memberId);
return new UsernamePasswordAuthenticationToken(userDetails, accessToken, authorities);
} catch (JwtException | IllegalArgumentException | NullPointerException exception) {
throw new BadCredentialsException(exception.getMessage());
}
}
그리고 난
토큰을 만드는 Provider 와
토큰을 검증하는 Parser 클래스를 나눠서 구현했다
먼저 JwtTokenProvider
@Component
@PropertySource("classpath:application.yml")
public class JwtTokenProvider {
@Value("${token.valid.time}")
private int tokenValidTime;
@Value("${token.access}")
private String accessToken;
@Value("${token.refresh}")
private String refreshToken;
private final JwtTokenRepository jwtTokenRepository;
private final JwtParser jwtParser;
private final Key key;
public JwtTokenProvider(@Value("${token.secret.key}") String secretKey, JwtTokenRepository jwtTokenRepository) {
this.jwtTokenRepository = jwtTokenRepository;
key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
}
public String createAccessToken(String memberId, Collection<? extends GrantedAuthority> role) {
Claims claims = getNewClaims(accessToken, tokenValidTime);
claims.put("memberId", memberId);
claims.put("role", role);
return Jwts.builder()
.setHeaderParam("typ", "Bearer")
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public String createRefreshToken() {
Claims claims = getNewClaims(refreshToken, tokenValidTime * 10);
return Jwts.builder()
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public Token saveRefreshInDB(Token refreshToken) {
// 기존 토큰이 있으면 바꿔치기, 없으면 새로 insert
Token findToken = jwtTokenRepository.findByMemberId(refreshToken.getMemberId());
if (findToken == null) {
return jwtTokenRepository.insertRefreshToken(refreshToken);
}
findToken.changeToken(refreshToken);
return jwtTokenRepository.updateNewToken(findToken);
}
private Claims getNewClaims(String tokenType, int tokenValidTime) {
Date now = new Date();
return Jwts.claims()
.setSubject(tokenType)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime));
}
public String reCreateJwtToken(String token) {
Claims claims = getOldClaims(token);
String memberId = claims.get("memberId", String.class);
String role = getRoleToString(claims);
if (StringUtils.hasText(role)) {
return createAccessToken(memberId, getAuthorities(role));
}
return null;
}
private String getRoleToString(Claims claims) {
List<Map<String, String>> roles = (List<Map<String, String>>) claims.get("role");
return roles.get(0).get("authority");
}
public Claims getOldClaims(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return claims;
}
private Collection<GrantedAuthority> getAuthorities(String role) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
public Token duplicationTokenDB(String memberId) {
return jwtTokenRepository.findByMemberId(memberId);
}
public Token setNewToken(Token token) {
return jwtTokenRepository.updateNewToken(token);
}
}
@Component
@PropertySource("classpath:application.yml")
public class JwtTokenProvider {
@Value("${token.valid.time}")
private int tokenValidTime;
@Value("${token.access}")
private String accessToken;
@Value("${token.refresh}")
private String refreshToken;
private final JwtTokenRepository jwtTokenRepository;
private final JwtParser jwtParser;
private final Key key;
public JwtTokenProvider(@Value("${token.secret.key}") String secretKey, JwtTokenRepository jwtTokenRepository) {
this.jwtTokenRepository = jwtTokenRepository;
key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
}
토큰 유효 시간, 액세스토큰이름, 리프레쉬토큰, 시크릿키 이름 등
모든 클래스에 공통 된 사항이거나,
보안상 너무 중요한 내용이어서 코드에 직접 노출하기 싫어서 @밸류 를 이용했다
시크릿키는 Keys.hmacShaKeyFor() 을 이용해 무작위 키를 만들어서 JwtParser에 넣어버렸다
보안 상의 이유도 있고, JwtParser 하나로 그냥 전부 해결이 가능해서 그런 것도 있음
public String createAccessToken(String memberId, Collection<? extends GrantedAuthority> role) {
Claims claims = getNewClaims(accessToken, tokenValidTime);
claims.put("memberId", memberId);
claims.put("role", role);
return Jwts.builder()
.setHeaderParam("typ", "Bearer")
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
public String createRefreshToken() {
Claims claims = getNewClaims(refreshToken, tokenValidTime * 10);
return Jwts.builder()
.setClaims(claims)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
}
액세스토큰엔 유저 정보를 넣었고
리프레쉬 토큰엔 딱 기본 정보만 넣었음...
public String reCreateJwtToken(String token) {
Claims claims = getOldClaims(token);
String memberId = claims.get("memberId", String.class);
String role = getRoleToString(claims);
if (StringUtils.hasText(role)) {
return createAccessToken(memberId, getAuthorities(role));
}
return null;
}
private String getRoleToString(Claims claims) {
List<Map<String, String>> roles = (List<Map<String, String>>) claims.get("role");
return roles.get(0).get("authority");
}
public Claims getOldClaims(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return claims;
}
private Collection<GrantedAuthority> getAuthorities(String role) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
그리고 기존 토큰이 있었을 경우(토큰 검증에 성공했을 경우) 발급해주는
새 토큰을 만드는 메서드인데,
기존 토큰을 파싱해서 memberId와 role을 가져온 후, 새 엑세스 토큰을 만들어주는 메서드다
public Token duplicationTokenDB(String memberId) {
return jwtTokenRepository.findByMemberId(memberId);
}
public Token setNewToken(Token token) {
return jwtTokenRepository.updateNewToken(token);
}
얘네는 이름 그대로
혹시 로그아웃을 안 해서 중복 토큰이 디비에 있나 확인하는 거구
중복 토큰이 있을 경우 새 토큰으로 교체하는 메서드들이다
JwtTokenParser
@Component
public class JwtTokenParser {
private final JwtParser jwtParser;
private final JwtTokenRepository jwtTokenRepository;
private static final String BEARER = "Bearer ";
public JwtTokenParser(@Value("${token.secret.key}") String secretKey, JwtTokenRepository jwtTokenRepository) {
this.jwtTokenRepository = jwtTokenRepository;
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
}
public boolean validToken(String token, boolean isRefreshToken) {
Claims claims = getClaims(token);
if (claims.getSubject() != null | !isRefreshToken) {
return !claims.getExpiration().before(new Date());
}
return false;
}
public Token getRefreshToken(String accessToken) {
Claims claims = getClaims(accessToken);
if (claims.getSubject() != null) {
String memberId = claims.get("memberId", String.class);
Token refreshToken = jwtTokenRepository.findByMemberId(memberId);
return refreshToken;
}
return null;
}
public Collection<GrantedAuthority> getAuthorities(String accessToken) {
Claims claims = getClaims(accessToken);
String role = getRoleToString(claims);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
private String getRoleToString(Claims claims) {
List<Map<String, String>> roles = (List<Map<String, String>>) claims.get("role");
return roles.get(0).get("authority");
}
public Claims getClaims(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return claims;
}
public String tokenInMemberId(String token) {
Claims claims = getClaims(token);
return String.valueOf(claims.get("memberId"));
}
public String getTokenHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.toLowerCase().startsWith(BEARER.toLowerCase())) {
System.out.println("bearerToken = " + bearerToken);
return bearerToken.substring(BEARER.length()).trim();
}
return null;
}
public String getTokenUrl(HttpServletRequest request) {
String token = request.getParameter("token");
if (StringUtils.hasText(token)) {
System.out.println("tokenUrl = " + token);
return token;
}
return null;
}
}
public JwtTokenParser(@Value("${token.secret.key}") String secretKey, JwtTokenRepository jwtTokenRepository) {
this.jwtTokenRepository = jwtTokenRepository;
Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.jwtParser = Jwts.parserBuilder().setSigningKey(key).build();
}
Provider 와 동일한 방법으로 key와 Parser를 만들어준다.(중요)
그러지 않으면 인코딩과 디코딩 방법이 달라져서 오류가 발생한다^^7
public boolean validToken(String token, boolean isRefreshToken) {
Claims claims = getClaims(token);
if (claims.getSubject() != null | !isRefreshToken) {
return !claims.getExpiration().before(new Date());
}
return false;
}
토큰의 유효성을 검증하는 간단한 코드
시간만 안 지났으면 바로 OK
public Token getRefreshToken(String accessToken) {
Claims claims = getClaims(accessToken);
if (claims.getSubject() != null) {
String memberId = claims.get("memberId", String.class);
Token refreshToken = jwtTokenRepository.findByMemberId(memberId);
return refreshToken;
}
return null;
}
액세스 토큰을 파싱해 멤버 아이디를 가져오고
그걸 토대로 DB에서 리프레쉬 토큰을 가져온다
public Collection<GrantedAuthority> getAuthorities(String accessToken) {
Claims claims = getClaims(accessToken);
String role = getRoleToString(claims);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
private String getRoleToString(Claims claims) {
List<Map<String, String>> roles = (List<Map<String, String>>) claims.get("role");
return roles.get(0).get("authority");
}
이게 쵸오큼 짜증났는데
원칙적으로 GrantAuthority는 배열로 되어있는데
claims.get("role", List.class) 로 받아버리면 타입 변환 에러가 뜬다
리스트를 LinkedHashMap 타입으로 변환이 어렵다는 에러던가..? 하여튼
나는 단일 역할을 이용할 거여서 SecurityConfig 에서 .hasRole() 을 사용하고 시펐는데
role을 그냥 스트링으로 받아버리면 배열을 그대로 스트링으로 받는 거라
hasRole은 사용이 안 된다 ( hasAuthority("ROLE_USER") 라고 사용해야 함 )
그래서 찾아본 결과
GrantAuthority는 List 안에 Map이 있는 형태여서
List<Map> 으로 꺼낼 수가 있었다. 그래서 저런 식으로 role 을 꺼내서 스트링으로 받아서...
그걸 단일 역할을 위해 new SimpleGrantAuthority() 를 사용해서 넣어주었다 ㅠㅠ(흑)
public String getTokenHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.toLowerCase().startsWith(BEARER.toLowerCase())) {
System.out.println("bearerToken = " + bearerToken);
return bearerToken.substring(BEARER.length()).trim();
}
return null;
}
public String getTokenUrl(HttpServletRequest request) {
String token = request.getParameter("token");
if (StringUtils.hasText(token)) {
System.out.println("tokenUrl = " + token);
return token;
}
return null;
}
헤더에 있는 토큰을 가져올 땐
bearer 을 떼고 가져오면 되는데
url에 붙어있는 토큰을 가져올 땐 그냥 그대로 가져오며는 된다^0^/
JwtTokenSetFilter
public class JwtTokenSetFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
String accessToken = String.valueOf(authentication.getCredentials());
if (StringUtils.hasText(accessToken)) {
response.setHeader("Authorization", "Bearer "+accessToken);
}
}
filterChain.doFilter(request, response);
}
}
이건 인증 요청마다 헤더에 토큰을 달아주는 필터를 만들어주었당!
authentication이 null이 아닐 경우(인증이 됐을 경우)
토큰을 가져와서 헤더에 넣어준다^0^/ ㅎㅎㅎ
원래는 모든 인증이 필요한 요청,
즉 인증 필요한 컨트롤러에 전부 저 코드가 들어가야하는데
이렇게 필터로 넣어주면 얘가 알아서 자동으로 돌기 때문에
우리는 뭔가 할 필요가 없음(룰루)
이제부터 나오는 애들은 JWT와 관련 된 애들은 아니고
그냥 시큐리티 기본 설정..
UserDetails
public class CustomUserDetails implements UserDetails {
private final Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(
new SimpleGrantedAuthority(
String.valueOf(member.getRole().getName())
));
return authorities;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return member.getMemberId();
}
@Override
public boolean isAccountNonExpired() {
return member.getPwdModifyDate().after(new Date());
}
@Override
public boolean isAccountNonLocked() {
return member.getStopDate() == null ? true : false;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return member.getRemoveDate() == null ? true : false;
}
}
유저디테일즈는 계정 정보 라고 생각하면 될 거 같다
이건 만약 이름이나 권한 정보만 필요하다면 굳이 UserDetails를 구현 안 해도 된다
근데 나는 왜 구현을 했냐면 !
@Override
public boolean isAccountNonExpired() {
return member.getPwdModifyDate().after(new Date());
}
@Override
public boolean isAccountNonLocked() {
return member.getStopDate() == null ? true : false;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return member.getRemoveDate() == null ? true : false;
}
유저디테일즈를 구현하면
계정 정지여부, 탈퇴여부, 인증 기한 설정(비밀번호 변경) 등
세세한 설정을 할 수가 있기 때문에
원하면 커스텀하는 것이 좋다 ^0^/
CustomUserDetailsService
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByMemberIdRole(username);
if (member == null) {
throw new UsernameNotFoundException("아이디가 없음");
}
return new CustomUserDetails(member);
}
}
이건 PasswordEncoder를 구현하려면 필수로 구현해줘야 하는 거라서 구현했다
PasswordEncoder와 UserDetailsService 는 짝궁이다
둘 중 하나를 커스텀하려면, 나머지도 함께 빈으로 등록해줘야 한다
- SecurityConfig 에 빈 등록함
@Bean
public UserDetailsService userDetailsService() {
return new CustomUserDetailsService(memberRepository);
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
그리고 loginForm.html 에 작성한 비동기 통신 코드도 첨부한다
(필요 없는 부분은 좀 잘랐음)
$( document ).ready( function() {
let urlParams = new URLSearchParams(window.location.search);
let redirectUrl = urlParams.get("redirectUrl");
$('#loginBtn').click(function () {
$.ajax({
type: 'POST',
url: '/login',
contentType: 'application/json',
data: JSON.stringify({memberId:memberId, password:password}),
dataType: 'json',
success: function(result) {
if (result.error) {
window.location.href='/loginForm?error='+result.error;
} else {
localStorage.setItem('jwt', result.JWT);
if (redirectUrl) {
window.location.href = redirectUrl+"?token="+result.JWT;
} else {
window.location.href = "/";
}
}
},
error: function(error) {
console.error(error);
}
});
})
})
let urlParams = new URLSearchParams(window.location.search);
let redirectUrl = urlParams.get("redirectUrl");
url에서 파라미터를 추출하는 코드다
redirectUrl 이라는 이름의 파라미터가 있다면 저장한다
$.ajax({
type: 'POST',
url: '/login',
contentType: 'application/json',
data: JSON.stringify({memberId:memberId, password:password}),
dataType: 'json',
success: function(result) {
if (result.error) {
window.location.href='/loginForm?error='+result.error;
} else {
localStorage.setItem('jwt', result.JWT);
if (redirectUrl) {
window.location.href = redirectUrl+"?token="+result.JWT;
} else {
window.location.href = "/";
}
}
},
error: function(error) {
console.error(error);
}
});
아이디, 패스워드를 로그인 컨트롤러에 넘기고 Map타입으로 받는다
(맵은 키밸류니까 json 이겠지?)
키 error 가 있을 경우, 에러페이지로 보낸다
로그인이 성공했다면
일단 토큰을 로컬스토리지에 저장한 후
리다이렉트url이 존재하면
해당 url로 이동하고, 파라미터로 토큰을 함께 보낸다
리다이렉트가 아니라 그냥 200 요청이었을 경우, 메인 페이지로 이동한다 ^0^/
그리고 스프링시큐리티를 사용하면 자동 로그인 기능이 있는데
jwt토큰 인증을 이용할 경우 로그인이 성공하면 토큰을 발급하고 프론트에 토큰을 넣어줘야하는데
자동 로그인을 하면 토큰 발급이 어려워서 직접 로그인을 구현한다
@Controller
public class MemberLoginController {
private final MemberLoginService memberLoginService;
public MemberLoginController(MemberLoginService memberLoginService) {
this.memberLoginService = memberLoginService;
}
@GetMapping("/loginForm")
public String loginForm(@RequestParam(name = "error", required = false) String error, Model model) {
model.addAttribute("loginForm", new LoginDto());
if (error != null && error.equals("error")) {
System.out.println("error = " + error);
model.addAttribute(error, "아이디 또는 비밀번호가 틀립니다");
}
return "member/loginForm";
}
@ResponseBody
@PostMapping("/login")
public Map loginToken(@RequestBody LoginDto loginDto, HttpServletResponse response) {
Map map = new HashMap();
if (loginDtoNullCheck(loginDto)) {
errorMapReturn(map);
}
Member member = null;
try {
member = new Member(loginDto);
boolean idPwdValid = memberLoginService.idPwdValid(member);
// idPwd 틀리면 오류폼
if (!idPwdValid) {
errorMapReturn(map);
}
} catch (UsernameNotFoundException e) {
System.out.println("아이디가 없음");
errorMapReturn(map);
return map;
}
Map<String, String> tokens = memberLoginService.getTokens(member);
String refreshToken = tokens.get("refreshToken");
String accessToken = tokens.get("accessToken");
// 각 토큰 저장
memberLoginService.saveRefreshToken(member.getMemberId(), refreshToken);
response.setHeader("Authorization", "Bearer "+accessToken);
/**
* 자동로그인기능 쿠키생성(나중에)
*/
map.put("JWT", accessToken);
System.out.println("error : "+ map.get("error"));
return map;
}
private void errorMapReturn(Map map) {
map.put("error", "error");
}
private boolean loginDtoNullCheck(LoginDto loginDto) {
if (loginDto == null) {
return true;
}
if (loginDto.getMemberId().equals("")) {
return true;
}
if (loginDto.getPassword().equals("")) {
return true;
}
if (loginDto.getMemberId().contains(" ")) {
return true;
}
if (loginDto.getPassword().contains(" ")) {
return true;
}
return false;
}
}
@ResponseBody
@PostMapping("/login")
public Map loginToken(@RequestBody LoginDto loginDto, HttpServletResponse response) {
Map map = new HashMap();
if (loginDtoNullCheck(loginDto)) {
errorMapReturn(map);
}
Member member = null;
try {
member = new Member(loginDto);
boolean idPwdValid = memberLoginService.idPwdValid(member);
// idPwd 틀리면 오류폼
if (!idPwdValid) {
errorMapReturn(map);
}
} catch (UsernameNotFoundException e) {
System.out.println("아이디가 없음");
errorMapReturn(map);
return map;
}
Map<String, String> tokens = memberLoginService.getTokens(member);
String refreshToken = tokens.get("refreshToken");
String accessToken = tokens.get("accessToken");
// 각 토큰 저장
memberLoginService.saveRefreshToken(member.getMemberId(), refreshToken);
/**
* 자동로그인기능 쿠키생성(나중에)
*/
map.put("JWT", accessToken);
return map;
}
로그인을 할 땐,
로그인이 성공하면 토큰을 발급해주고
리프레쉬는 디비에, 액세스토큰은 map에 넣어 프론트로 보낸다!
@Transactional
@Service
public class MemberLoginServiceImpl implements MemberLoginService {
private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
public MemberLoginServiceImpl(UserDetailsService userDetailsService, BCryptPasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean idPwdValid(Member member) {
UserDetails userDetails = getUserDetails(member);
return passwordEncoder.matches(member.getPassword(), userDetails.getPassword());
}
@Override
public Map<String, String> getTokens(Member member) {
UserDetails userDetails = getUserDetails(member);
String refreshToken = jwtTokenProvider.createRefreshToken();
String accessToken = jwtTokenProvider.createAccessToken(
userDetails.getUsername(), userDetails.getAuthorities());
Map<String, String> map = new HashMap<>();
map.put("accessToken", accessToken);
map.put("refreshToken", refreshToken);
return map;
}
@Override
public void saveRefreshToken(String memberId, String refreshToken) {
Token token = jwtTokenProvider.duplicationTokenDB(memberId);
if (token != null) {
jwtTokenProvider.setNewToken(new Token(token.getId(), memberId, refreshToken));
} else {
jwtTokenProvider.saveRefreshInDB(new Token(memberId, refreshToken));
}
}
private UserDetails getUserDetails(Member member) {
return userDetailsService.loadUserByUsername(member.getMemberId());
}
}
@Override
public boolean idPwdValid(Member member) {
UserDetails userDetails = getUserDetails(member);
return passwordEncoder.matches(member.getPassword(), userDetails.getPassword());
}
간단하게 idPwd 검증하는 메서드
@Override
public Map<String, String> getTokens(Member member) {
UserDetails userDetails = getUserDetails(member);
String refreshToken = jwtTokenProvider.createRefreshToken();
String accessToken = jwtTokenProvider.createAccessToken(
userDetails.getUsername(), userDetails.getAuthorities());
Map<String, String> map = new HashMap<>();
map.put("accessToken", accessToken);
map.put("refreshToken", refreshToken);
return map;
}
액세스토큰과 리프레쉬 토큰을 발급해준다
@Override
public void saveRefreshToken(String memberId, String refreshToken) {
Token token = jwtTokenProvider.duplicationTokenDB(memberId);
if (token != null) {
jwtTokenProvider.setNewToken(new Token(token.getId(), memberId, refreshToken));
} else {
jwtTokenProvider.saveRefreshInDB(new Token(memberId, refreshToken));
}
}
로그인을 했는데 기존 토큰이 DB에서 사라지지 않았을 경우
기존 토큰에 덮어씌우기 위해 만들었다
-
결과는?
/fanLetter 주소로 들어가면
자동으로 LoginForm으로 이동된다.
이 때, 리다이렉트 url도 같이 가져온다
로그인이 성공하면?
url에 토큰이 들어오고
HTTP 요청 200 정상 응답을 볼 수 있다!
헤더도 정상으로 세팅돼있고
로컬스토리지에도 정상적으로 토큰이 저장되어 있는 걸 볼 수 있다 ㅎㅎ
-
이거 구현하려고 시큐리티 관련 서적도 사고
구글 검색도 엄청 하고
친구한테 물어보기도 하고
챗GPT한테 물어보기도 하고
여럿의 도움 끝에 완성했는데....
이렇게까지 해야되나 ?? 굳이???? 싶긴 하다
일단 내가 만들 사이트는 볼륨이 작기도 하고
서버 부하가 세션 로그인에 비해 덜하다고 했지만...
리프레쉬 토큰은 보안을 위해 서버에 저장하는 것이 안전하다고 하여
내 경우는 DB에 리프레쉬 토큰을 저장하는데
인증이 필요할 때마다 액세스 토큰 또는 리프레쉬 토큰을 확인하고
유효 토큰일 경우 리프레쉬 토큰을 재발급해 DB업데이트를 한다
그러면 DB에 계속 들리는 건데...
이게 과연 세션 로그인보다 서버 부하에 도움이 되는 건가??? 싶긴 함
(사실 실무자가 아니어서 잘 모르지만..)
나중에 면접에 가게 되면 고수 실무자 면접관 분들께 함 물어보고 싶긴 하다
세션 사용이 불가능한 애플리케이션이라면 토큰 사용이 필수적이지만
굳이 세션 사용이 가능한 웹사이트에서 세션보다 보안에 취약한 JWT 토큰을 사용할 필요가 있나 싶음 ㅡ.ㅡ
나는 취업을 위한 프로젝트여서
나 세션로그인도 구현 가능하고 토큰로그인도 구현 가능해요~ 보여주기 위해 일부러 구현한 거지만
만약 내가 실용적인 나만의 애플리케이션을 만든다면
구우우우욷이 JWT 토큰을 사용하진 않을 거 같음
+) 2023-11-06 기준으로
위 생각에 대한 의견이 조금 바뀌었다. 그건 다음 JWT 포스팅에 작성하겠다.ㅎㅎ
그래도 만들기 어려운 게 꼭 대단하고 좋은 건 아니라고 생각함...각자 존재 이유가 있는거지.
하여튼!!!
이거때문에 맘고생 몸고생 좀 했는데
이제부턴 좀 편하게 플젝 할 수 있을듯 ㅠㅠ 흐흐...
'혼자서 개발새발' 카테고리의 다른 글
Spring Security ) SNS 로그인(OAuth2) + JWT + redirect 를 전부 구현하기 (0) | 2023.05.06 |
---|---|
Pageable 로 게시판 페이징을 해야 하는데 특정 컬럼이 존재한다면 안 보여주고 싶다 (0) | 2023.05.05 |
thymeleaf 템플릿을 사용해서 메일로 코드 발송하기! (0) | 2023.03.12 |
Spring Security ) SNS 로그인 구현(내가 보려고 정리...) (1) | 2023.01.09 |
페이징 할 때 Entity로 받고, Dto로 변환하자! (1) | 2023.01.05 |