1. 로그인 구현
비밀번호가 일치하면 토큰을 생성해서 내려준다.
구현해야할 것
- 로그인 컨트롤러
- 로그인 서비스
- JwtUtils 클래스에 token 생성 로직
컨트롤러
@PostMapping("/login")
public ResponseEntity<SignIn.Response> signIn(
@RequestBody @Valid SignIn.Request request) {
return ResponseEntity.ok(memberService.signIn(request));
}
서비스
public SignIn.Response signIn(SignIn.Request request) {
Member member = getMemberByEmail(request.getEmail());
validatedPassword(request, member);
return SignIn.Response.toResponse(
jwtUtils.generateToken(request.getEmail(), "ATK")
, jwtUtils.generateToken(request.getEmail(), "RTK"));
}
JwtUtils
JWT 개념과 전체적인 스프링 시큐리티 인증과정 참고
스프링 핵심가이드) 북스터디 8주차 : 13장 서비스의 인증과 권한 부여 (tistory.com)
JWT는 Header, Payload, Signature 구조로 되어있다.
- Header
- kid : 서명시 사용하는 키
- typ : 토큰 유형
- alg : 서명 암호화 알고리즘
- Payload : 토큰에서 사용할 정보 조각들인 Claim이 담겨있음.
- iss (issue) : 토큰 발급자
- sub (subject) : 토큰 제목
- iat (issued at) : 토큰 발급 시간
- exp (expireation) : 토큰 만료 시간
- roles : 권한
여기서 roles는 public claims고 roles는 private claims다.
- signature : 헤더에서 정의한 알고리즘 방식 사용.
package com.sj.Petory.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtUtils {
private static final String TOKEN_TYPE = "token_type";
private final UserDetailsService userDetailsService;
@Value("${spring.jwt.expiredAt.ATK}")
private String expiredAt_ATK;
@Value("${spring.jwt.expiredAt.RTK}")
private String expiredAt_RTK;
@Value("${spring.jwt.secret}")
private String secretKey;
public String generateToken(final String email, final String tokenType) {
Claims claims = Jwts.claims().setSubject(email);
claims.put(TOKEN_TYPE, tokenType);
Date now = new Date();
Date expiredDate = setExpired(tokenType, now);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiredDate)
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
private Date setExpired(String tokenType, Date now) {
if (tokenType.equals("ATK")) {
return new Date(now.getTime() + Long.parseLong(expiredAt_ATK));
}
if (tokenType.equals("RTK")) {
return new Date(now.getTime() + Long.parseLong(expiredAt_RTK));
}
return null;
}
}
1. application.yml에 ATK, RTK 만료시간과 암호화에 사용할 SecretKey를 등록하였다.
2. JWT의 payload에 들어갈 Claims 객체에 subject로 email을 넣었다.
accesstoken과 refreshtoken을 구분하기 위해 token_type도 추가로 넣었다.
3. builder패턴으로 Jwt 토큰을 생성한다.
token 타입에 따른 만료시간을 다르게 설정해주기 위해 setExpired() 메소드를 추가로 구현하였다.
이렇게 구현해주면 로그인 시 accsstoken과 refreshtoken을 발급하여 응답으로 내려주게 된다.
Trouble
2024-08-14T01:00:08.319+09:00 ERROR 22424 --- [Petory] [nio-8080-exec-5] c.s.P.exception.GlobalExceptionHandler : jakarta.servlet.ServletException : Handler dispatch failed: java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter
원인 : JAXB API가 java11에서부터는 완전히 제거되어 의존성을 추가로 넣어주어야 한다.
해결 :
https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api/2.3.1에서 javax.xml.bind 검색 후 의존성 추가
implementation 'javax.xml.bind:jaxb-api:2.3.1'
2. 인증이 필요한 API 구현
회원 정보를 조회하는 페이지에 들어가기 위해서는 로그인 한 회원 정보가 필요하다.
직접 컨트롤러 파라미터로 요청 헤더에 담겨오는 토큰을 받아서 파싱해도 되지만 @AuthenticationPrincipal을 사용해서 로그인한 Authentication 객체를 가져오기로 했다.
@AuthenticationPrincipal에 대한 정보는 아래 포스팅으로 확인
GiftFunding) 헤더에 JWT 토큰으로 로그인 후 사용자 정보 얻기 (tistory.com)
해당 API를 구현하기 위해서는 아래의 것들을 만들어줘야 한다.
- 회원정보 조회 컨트롤러
- 회원정보 조회 서비스
- JwtUtils에 받아올 토큰을 파싱하는 로직
- Filter를 이용해 요청이 들어올 때마다 헤더에 토큰을 파싱해서 검증해주는 로직
- @AutenticationPrincipal은 스프링 시큐리티의 인증이 완료된 객체인 UserDetils를 가지고 있으므로 이를 상속받아 필요한 정보만 뽑아올 MemberAdapter dto
- UserDetails 객체를 뽑아오기 위해 UserDetailsService의 loadByUser() 메소드를 사용해야한다. 이를 위해 memberService가 UserDetailsService를 implements해서 loadByUser()메소드를 구현해야 함.
컨트롤러
로그인 한 사용자 정보를 받아오기 위해서는 UserDetails를 상속받을 dto 객체에 @AuthenticationPrincipal을 붙이면 된다.
@GetMapping
public ResponseEntity<MemberInfoResponse> getMembers(
@AuthenticationPrincipal MemberAdapter memberAdapter) {
return ResponseEntity.ok(memberService.getMembers(memberAdapter));
}
서비스
public MemberInfoResponse getMembers(final MemberAdapter memberAdapter) {
Member member = getMemberByEmail(memberAdapter.getEmail());
return MemberInfoResponse.fromEntity(member);
}
MemberInfoResponse
package com.sj.Petory.domain.member.dto;
import com.sj.Petory.domain.member.entity.Member;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberInfoResponse {
private String name;
private String phone;
private String image;
public static MemberInfoResponse fromEntity(Member member) {
return MemberInfoResponse.builder()
.name(member.getName())
.phone(member.getPhone())
.build();
}
}
이렇게만 만들어주면 SecurityContext에 Authentication이 없어서 @AuthenticationPrincipal로 MemberAdapter 객체를 받아오지 못 한다. 따라서 다음과 같은 오류가 난다.
정상적인 인증 과정을 거치기 위해 추가해야할 작업은 다음과 같다.
JwtAuthenticationFilter
- OncePerRequestFilter를 상속받아 매 요청시마다 한 번씩 필터를 거치도록 한다.
- HttpServletRequest header에서 토큰을 가져온다. (JwtUtils.resolveToken())
- 가져온 토큰이 유효한지 확인한다.(JwtUtils.validateToken())
- 토큰을 통해 Authentication 객체를 얻는다. (JwtUtils.getAuthentication())
- 가져온 Authentication을SecurityContextHolder에 추가한다.
JwtUtils
- resolveToken() : HttpServletRequest Header에서 토큰을 뽑아오는 메소드
- validateToken() : 토큰의 만료시간을 검증하여 토큰이 유효한지 확인하는 메소드
- getAuthentication() : Authentication을 만드는 메소드. 여기서 UserDetailsService의 loadUserByUsername()이 사용됨
- parseClaims() : jwt토큰에서 claims를 파싱하는 메소드
JwtAuthenticationFilter
package com.sj.Petory.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtils.resolveToken(request);
if (StringUtils.hasText(token) && jwtUtils.validateToken(token)) {
Authentication authentication = jwtUtils.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
1. jwtUtils.resolveToken(request)
: 헤더를 메소드의 파라미터로 보내 token값을 얻는다.
2. if 조건문
: 토큰 값이 있고 validateToken() 메소드를 통해 만료되지 않았으면 안의 코드를 실행
3. Authentication authentication = jwtUtils.getAuthentication(token);
: getAuthentication() 메소드를 통해 Authentication 객체를 얻어온다.
4. 3에서 얻어온 Authentication 객체를 SecurityContextHolder에 저장한다.
JwtUtils
public String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (!StringUtils.hasText(header) || !header.startsWith(TOKEN_PREFIX)) {
return null;
}
return header.substring(TOKEN_PREFIX.length());
}
resolveToken() : Http 요청 헤더에서 Authorization으로 들어온 값을 Token prefix를 빼고 반환합니다.
public boolean validateToken(String token) {
Date exp = parseClaims(token).getExpiration();
if (exp.before(new Date())) {//날짜 개념 학습
throw new MemberException(ErrorCode.TOKEN_EXPIRED);
}
return true;
}
validateToken() : Jwts.parser()를 이용해 토큰을 파싱한다.
.setSigningKey()로 토큰 생성할 때 넣어줬던 secretKey를 넣어줘야하고
.parseClaimsJws(token)으로 토큰의 Claims를 파싱해준다.
거기서 .getBody().getExpiration()으로 만료일자를 가져와 조건문으로 비교해준다.
parseClaims()메소드는 아래 코드에 있다.
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(
parseEmail(token));
return new UsernamePasswordAuthenticationToken(
userDetails
, ""
, userDetails.getAuthorities()
);
}
private String parseEmail(String token) {
return parseClaims(token).getSubject();
}
private Claims parseClaims(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token).getBody();
}
getAuthentication() : UserDetailsService의 loadUserByUsername() 메소드를 통해 UserDetails 객체를 받아오고
Authentication을 반환한다.
Authentication 객체를 만들 때 UsernamePasswordAuthenticationToken(principal, credentials, authorities)을 넣어준다.
여기서 pricipal에는 UserDetails객체를, authorities에는 userDetails.getAuthorities()로
권한 정보를 넣어줘야한다.
참고로 userDetails.getAuthorities를 안 넣어주면 인증 객체가 생성되어도 Autheticated = false로 뜨며 api 요청 시 forbidden이 뜨게 된다.
parseCliams는token에서 Claims를 가져오는 메소드이고 parseEmail은 토큰에서 subject(토큰 생성 시 subject로 토큰을 저장해놓음) 를 가져오는 메소드이다.
Trouble
2024-08-15T19:15:36.953+09:00 INFO 25608 --- [Petory] [nio-8080-exec-4] c.s.P.security.JwtAuthenticationFilter : context 잘 됐니 ?UsernamePasswordAuthenticationToken [Principal=com.sj.Petory.domain.member.dto.MemberAdapter@343c5163, Credentials=[PROTECTED], Authenticated=false, Details=null, Granted Authorities=[]]
해당 로그와 함께 인증 필요한 API 요청 시 403 Forbidden 에러 발생
UsernamePasswordAuthenticationToken 생성자는 세 번째 매개변수로 권한 목록을 받으며, 이를 통해 인증된 사용자로 설정 된다.
해결 : getAuthentication()에 new UsernamePasswordAuthenticationToken()의 세 번째 인자로 userDetails.getAuthorities()를 넣어준다.
코드 전문
package com.sj.Petory.security;
import com.sj.Petory.domain.member.dto.MemberAdapter;
import com.sj.Petory.domain.member.service.UserDetailsServiceImpl;
import com.sj.Petory.exception.MemberException;
import com.sj.Petory.exception.type.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtUtils {
private static final String TOKEN_TYPE = "token_type";
private final UserDetailsServiceImpl userDetailsService;
@Value("${spring.jwt.expiredAt.ATK}")
private String expiredAt_ATK;
@Value("${spring.jwt.expiredAt.RTK}")
private String expiredAt_RTK;
@Value("${spring.jwt.secret}")
private String secretKey;
public String generateToken(final String email, final String tokenType) {
Claims claims = Jwts.claims().setSubject(email);
claims.put(TOKEN_TYPE, tokenType);
Date now = new Date();
Date expiredDate = setExpired(tokenType, now);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiredDate)
.signWith(SignatureAlgorithm.HS512, secretKey)
.compact();
}
private Date setExpired(String tokenType, Date now) {
if (tokenType.equals("ATK")) {
return new Date(now.getTime() + Long.parseLong(expiredAt_ATK));
}
if (tokenType.equals("RTK")) {
return new Date(now.getTime() + Long.parseLong(expiredAt_RTK));
}
return null;
}
public String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
return null;
}
return header.substring("Bearer ".length());
}
public boolean validateToken(String token) {
Date exp = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getExpiration();
if (exp.before(new Date())) {//날짜 개념 학습
throw new MemberException(ErrorCode.TOKEN_EXPIRED);
}
return true;
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(
parseEmail(token));
return new UsernamePasswordAuthenticationToken(
userDetails
, ""
, userDetails.getAuthorities()
);
}
private String parseEmail(String token) {
return parseClaims(token).getSubject();
}
private Claims parseClaims(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token).getBody();
}
}
MemberAdapter
UserDetails를 인증 객체로 사용할 것이기 때문에 이를 상속한 dto가 필요하다.
entity에 UserDetails를 받아오는 것은 영속성 문제로 좋지 않으니 꼭 dto를 만들어 상속받도록 하자.
GiftFunding) 헤더에 JWT 토큰으로 로그인 후 사용자 정보 얻기 (tistory.com)
package com.sj.Petory.domain.member.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
@Getter
@RequiredArgsConstructor
@Builder
public class MemberAdapter implements UserDetails{
private final String email;
private final String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.email;
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
UserDetailsServiceImpl
UserDetailsService인터페이스를 상속받을 클래스를 생성한다.
loadByUsername() 메소드를 구현하여 위에서 만든 MemberAdapter 객체를 반환하도록 해야한다.
package com.sj.Petory.domain.member.service;
import com.sj.Petory.domain.member.dto.MemberAdapter;
import com.sj.Petory.domain.member.entity.Member;
import com.sj.Petory.domain.member.repository.MemberRepository;
import com.sj.Petory.exception.MemberException;
import com.sj.Petory.exception.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new MemberException(ErrorCode.MEMBER_NOT_FOUND));
return new MemberAdapter(member.getEmail(), member.getPassword());
}
}
loadUserByUsername 메소드는 DB에서 인증된 객체인 UserDetails를 반환하는데 MemberAdapter는 위에서 UserDetails를 상속받았으므로 반환에 꼭 MemberAdapter를 사용한다.
(이래야 나중에 컨트롤러에서 @AuthenticationPrincipal로 로그인 한 사용자 정보를 받아올 때 사용할 수 있다.)
SeurityConfig
package com.sj.Petory.config;
import com.sj.Petory.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers ->
headers
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.authorizeHttpRequests((authz) ->
authz
.requestMatchers(HttpMethod.GET, "/members").authenticated()
.requestMatchers(HttpMethod.POST, "/members").permitAll()
.requestMatchers(
"/members/check-email"
, "/members/check-name"
, "members/login"
, "/h2-console/**"
, "/docs/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 추가
return http.build();
}
}
제일 마지막에 addFilterBefore가 없으면 403 forbidden 에러가 나게 된다.
해당 코드는 커스텀으로 생성한 JwtAuthenticationFilter를 Spring Security의 필터 체인에서 실행되도록 하는 것이다.
이 설정이 없으면 JwtAuthenticationFilter가 Spring Security의 필터 체인에서 실행되지 않아 JWT 인증/검증 과정이 이루어지지 않는다.
JwtAuthenticationFilter는 인증을 담당하는 필터이기 때문에 UsernamePasswordAuthenticationFilter 전에 실행되어야 한다.
UsernamePasswordAuthenticationFilter는 기본적으로 폼 기반 로그인 처리에 사용되는 필터이다.
따라서 JWT를 기반으로 한 인증을 먼저 처리하기 위해 JwtAuthenticationFilter가 UsernamePasswordAuthenticationFilter보다 먼저 실행되도록 설정한다.
위와 같이 코드를 작성하면
이렇게 인증이 필요한 객체에서 Authorization 으로 Bearer이 붙은 jwtToken을 보내줬을 때 정상적인 인증이 가능해진다 !
이번에 로그인 및 인증을 구현하면서 JWT의 구조와 생성, 파싱하는 것을 더 습득한 것 같다.
특히 Spring Security 인증 과정이 어렵기만 했는데 습득된 거 같아서 매우 뿌듯하다 !!! 😁😁
참고
HandleDispatch fail 에러 해결
https://devthriver.tistory.com/13
스프링 시큐리티 설정
스프링 시큐리티 기본 개념과 구조
JWT 생성 참고
Security Security Config
시큐리티 JWT 구성
'공부 > Spring Boot' 카테고리의 다른 글
SpringBoot에 Flyway적용해서 DB 형상 관리 하기 + JPA 옵션, flyway 옵션 정리 (0) | 2024.08.26 |
---|---|
Spring Boot) 백 & 프론트 카카오 로그인 API 구현하기 (4) | 2024.08.24 |
Spring Boot 3.x 버전에서 Spring Security 적용기 (2) | 2024.08.13 |
Spring Boot) 회원가입 시 PasswordEncoder 이용해 비밀번호 암호화 (0) | 2024.08.12 |
SpringBoot 3.x 버전에서 RestDocs + SwaggerUI 사용하기 (0) | 2024.08.01 |