안녕하세요.
오늘은 스프링 시큐리티를 사용해서 기본적인 회원가입과 로그인 기능을 구현해보도록 하겠습니다.
JWT 객체
우선 가장 기본적인 클라이언트에게 반환할 JWT 객체를 먼저 구현하도록 하겠습니다.
public class JwtToken {
private String accessToken;
private String refreshToken;
}
기본적인 엑세스 토큰과 더불어 엑세스 토큰 만료시 재발급 받을 수 있도록 리프레쉬 토큰도 넣어서 로그인 시 클라이언트에게 반환해 주기 위해 해당 객체를 만들었습니다.
JwtTokenProvider
이번에는 스프링 내에서 Jwt 토큰 생성, 증명 등 관련 작업을 수행하는 객체를 만들도록 하겠습니다.
public class JwtTokenProvider {
private final Key key;
private final int ACCESS_TOKEN_EXPIRED_TIME = 60 * 30 * 1000;
private final int REFRESH_TOKEN_EXPIRED_TIME = 60 * 60 * 1000 * 24;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
key = Keys.hmacShaKeyFor(secretByteKey);
}
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRED_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보 X");
}
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch(ExpiredJwtException e) {
return e.getClaims();
}
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch(io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
}
우선 jwt토큰을 생성해서 발급해주기 위해서는 secret key가 필요합니다. JWT는 공개 키로 데이터를 암호화하고 비밀 키로 서명을 하여 access token을 발급하게 됩니다. 자세한 내용은 이 곳을 확인해보시면 좋을 것 같습니다 :)
그래서 일단 secret key 는 랜덤하게 생성해 주셔도 되지만 저는
그냥 위 명령어를 통해 생성한 값을 key로 사용하기로 했습니다!
그리고 위와 같이 application.yml 내에 해당 정보를 넣어주었습니다.
이제 method 하나씩 살펴보도록 하겠습니다.
public JwtToken generateToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRED_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return JwtToken.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
이번 함수는 이름에서 유추할 수 있듯이 토큰을 생성하는 함수입니다. 유저의 권한을 토큰에 포함시켜서 생성해주는데요, 보시면 Jwts를 사용하여 토큰을 생성했습니다.
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
해당 라이브러리는 위 의존성을 추가해주시면 사용할 수 있습니다. 그리고 처음 만들어주었던 JwtToken객체에 토큰들을 집어넣고 반환하는 것을 볼 수 있습니다.
getAuthentication 함수는 Jwt필터를 만들 때 알아보도록 하겠습니다.
마지막으로 validateToken 함수는 token이 유효한지 확인해주는 함수입니다.
JwtAuthenticationFilter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
이 코드는 JWT토큰을 request에 포함했을 때 인증을 해주기 위해 구현된 필터입니다. 사실 오늘은 회원가입, 로그인만 구현할 것이기 때문에 사용되지 않는 필터이긴 한데요. 후에 게시판 기능도 구현할 것이기 때문에 만들어줬습니다.
OncePerRequestFilter를 상속함으로 한 Http요청당 한 번만 실행되는 것을 보장합니다.
메소드를 확인해보면 특별한 것은 없고 유효한 토큰인지 확인하고 Security Context에 인증 객체를 저장하는 과정임을 알 수 있습니다.
UserDetailService
public class MemberUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new RuntimeException());
}
public UserDetails createUserDetails(Member member) {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(member.getRole().toString());
return new User(member.getEmail(), member.getPassword(), Collections.singletonList(simpleGrantedAuthority));
}
}
다음은 UserDetailsService입니다.
직접 구현한 Member객체를 사용할 것이기 때문에 UserDetailsService도 구현해주었습니다. username을 받아서 해당 username과 같은 Member를 찾아서 반환해주는 loadUserByUsername을 오버라이딩 하였습니다. 이제 스프링 시큐리티에서 저 함수를 통해 찾은 멤버로 아이디와 비밀번호의 일치 여부를 통해 유효한 로그인 요청인지를 판별할 것입니다 :)
마찬가지로 UserDetails도 직접 상속해서 구현해줄 수 있지만 복잡한 부분이 없기 때문에 같이 구현해주었습니다. 사용자 정보를 조회해서 반환해줍니다.
SecurityConfig
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public BCryptPasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.authorizeRequests()
.antMatchers("/api/members").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
스프링 시큐리티 설정입니다. 해당 설정과 동일하게 해주셔야 정상적으로 jwt필터를 등록할 수 있고, 세션 방식을 사용하지 않을 수 있습니다.
API 구현
`public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<SignUpResponse> signUp(@RequestBody SignUpRequest request) {
return new ResponseEntity<>(memberService.signUp(request), HttpStatus.OK);
}
@PostMapping("/signin")
public ResponseEntity<JwtToken> signIn(@RequestBody SignInRequest request) {
return new ResponseEntity<>(memberService.signIn(request), HttpStatus.OK);
}
}`
`public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public SignUpResponse signUp(SignUpRequest request) {
Member member = new Member(request, bCryptPasswordEncoder);
return new SignUpResponse(memberRepository.save(member));
}
public JwtToken signIn(SignInRequest request) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
request.getEmail(), request.getPassword());
Authentication authenticate = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
return jwtTokenProvider.generateToken(authenticate);
}
}`
컨트롤러와 서비스단입니다. 간단하게 회원가입, 로그인 API만 작성을 했습니다.
회원가입은 간단하게 이메일, 비밀번호 받아서 바로 member 객체 저장하도록 했습니다. 이 때 주의할점은 패스워드를 암호화해서 저장하지 않으면 저장할 때는 문제가 없지만 후에 로그인 시도를 할 때 시큐리티에서 오류를 내기 때문에 꼭 암호화해서 저장해주셔야 합니다.
로그인도 비슷하게 이메일, 비밀번호 받아서 시큐리티에서 인증이 된다면 구현해놓은 JwtTokenProvider에서 토큰을 생성해서 반환해주게 됩니다.
구현 결과
회원가입 정상적으로 진행됩니다.
로그인도 마찬가지로 성공한다면 엑세스토큰과 리프레쉬 토큰을 정상적으로 잘 발급해주는 것을 확인할 수 있습니다.
이렇게 비밀번호가 틀리면 오류를 내줍니다 :)
마치며
지금까지 스프링 시큐리티와 JWT 토큰 방식을 사용해서 회원가입, 로그인 기능을 구현해봤습니다. 사실 처음 구현해보는 거라 너무 오래걸렸네요. 다음 시간에는 게시판 구현해보도록 하겠습니다 :)
'개발 > 스프링부트' 카테고리의 다른 글
LogBack과 슬랙, 텔레그램 봇 사용하기 with Springboot (0) | 2023.06.27 |
---|---|
LogBack과 디스코드 봇 사용하기 with Springboot (0) | 2023.06.27 |