쇼핑몰에서 JWT를 이용하여 로그인 기능을 구현하려면
코드를 어떻게 작성해야 할까?
환경
--
- IntelliJ Community 2023.1.5
- Spring Boot 3.2.1
- JDK 17
build.gradle [ dependencies ]
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
--
Session과 JWT 중에서 JWT를 선택한 이유
--
로그인 인증 Session과 JWT 중에 무엇을 사용할까?
--
Spring Security 구조
--
--
Security Config 변경사항
--
Spring Security 버전업을 하면서 더 이상 지원하지 않는 기능들이 생겼다.
그 중에서 대표적으로
Spring Security 5.7 버전 이상부터는 더 이상 WebSecurityConfigurerAdapter를 지원하지 않는다.
기존에는 바로 상속 받아서 사용을 했다.
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
}
}
하지만 WebSecurityConfigurerAdapter를 지원하지 않으면서
이제는 SecurityFilterChain을 @Bean으로 등록하여 사용하는 방법을 권장하는 방법으로 변경되었다.
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
}
--
BCryptPasswordEncoder를 @Bean 으로 등록하기
--
JWT와 관련은 없지만 회원 및 관리자 가입을 할 때 비밀번호를 암호화하여 데이터베이스에 저장하기 위해
BCryptPasswordEncoder 인터페이스를 @Bean으로 등록하여 사용할 수 있게 한다.
ShopApplication.java
@EnableJpaAuditing
@EnableScheduling
@SpringBootApplication
public class ShopApplication {
// 비밀번호를 암호화하여 데이터베이스에 저장하기 위해 BCryptPasswordEncoder 인스턴스를 빈으로 등록
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
--
JWT에서 사용할 상수 정의하기
--
JWTProperties.java
public interface JWTProperties {
// 우리 서버만 알고 있는 비밀 값
String ACCESS_SECRET = "foAjdfk3we2wk4dfkfdfdDG9df734fdf86DG87dgG8dFd8fdG2";
String REFRESH_SECRET = "di3kd8bgj4jkf7sjk29dkUF4jnf982nfj48fkxbvoe02jz63ccj3b";
// BASE64로 디코딩하여 btye 배열 추출
byte[] accessKeyBytes = Decoders.BASE64.decode(JWTProperties.ACCESS_SECRET);
byte[] refreshKeyBytes = Decoders.BASE64.decode(JWTProperties.REFRESH_SECRET);
// JWT 토큰 서명에 사용할 서명 키
Key ACCESS_KEY = Keys.hmacShaKeyFor(accessKeyBytes);
Key REFRESH_KEY = Keys.hmacShaKeyFor(refreshKeyBytes);
// 토큰의 유효기간 (ms 단위)
int ACCESS_TIME = 60000*30; // access Token 만료(1분 * 30)
int REFRESH_TIME = 60000*60*24; // refresh Token 만료(1분 * 60 * 24)
// 토큰에 포함될 문자열
String TOKEN_PREFIX = "Bearer ";
// HTTP의 Header에서 토큰을 나타낼 문자열(이름)
String HEADER_STRING = "Authorization";
String REFRESH_STRING = "Refresh_Token";
// 토큰 발급자
String ISSUER = "ADIMN_AN";
}
ACCESS_SECRET와 REFRESH_SECRET 상수는
보기 편하게 JWTProperties에 임시로 작성한 것일 뿐이므로
유출되면 안되며 서버에서만 알고 있어야 하는 값이어야 한다.
application.yml 파일에 작성하고 gitignore에 등록하여 외부인은 볼 수 없게 만든다.
--
JWT 처리를 위한 유틸리티 작성
--
JwtFuntion.java
@Component
@Slf4j
public class JwtFunction {
// 회원 access 토큰 생성
public String createAccessToken(Member member)
{
Claims claims = Jwts.claims();
claims.put("memberId", member.getMemberId());
claims.put("loginId", member.getLoginId());
claims.put("roles", member.getRoles());
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuer(JWTProperties.ISSUER)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWTProperties.ACCESS_TIME))
.signWith(JWTProperties.ACCESS_KEY, SignatureAlgorithm.HS256)
.compact();
accessToken = JWTProperties.TOKEN_PREFIX + accessToken;
return accessToken;
}
// 회원 refresh 토큰 생성
public String createRefreshToken(Member member)
{
String refreshToken = Jwts.builder()
.setIssuer(JWTProperties.ISSUER)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWTProperties.REFRESH_TIME))
.signWith(JWTProperties.REFRESH_KEY, SignatureAlgorithm.HS256)
.compact();
refreshToken = JWTProperties.TOKEN_PREFIX + refreshToken;
return refreshToken;
}
// 관리자 access 토큰 생성
public String createAccessToken(Admin admin)
{
Claims claims = Jwts.claims();
claims.put("adminId", admin.getAdminId());
claims.put("loginId", admin.getLoginId());
claims.put("roles", admin.getRoles());
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuer(JWTProperties.ISSUER)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWTProperties.ACCESS_TIME))
.signWith(JWTProperties.ACCESS_KEY, SignatureAlgorithm.HS256)
.compact();
accessToken = JWTProperties.TOKEN_PREFIX + accessToken;
return accessToken;
}
// 관리자 refresh 토큰 생성
public String createRefreshToken(Admin admin)
{
String refreshToken = Jwts.builder()
.setIssuer(JWTProperties.ISSUER)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + JWTProperties.REFRESH_TIME))
.signWith(JWTProperties.REFRESH_KEY, SignatureAlgorithm.HS256)
.compact();
refreshToken = JWTProperties.TOKEN_PREFIX + refreshToken;
return refreshToken;
}
// 헤더에 있는 토큰에서 "Bearer "를 뺀 토큰 유효 검증 (access Token 검증)
public boolean validateAccessToken(String token){
try {
Jwts.parserBuilder().setSigningKey(JWTProperties.ACCESS_KEY).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
log.info("잘못된 JWT access 토큰 형식입니다.");
} catch (ExpiredJwtException e){
log.info("JWT access 토큰의 유효 기간이 지났습니다.");
} catch (UnsupportedJwtException e){
log.info("지원하지 않는 JWT access 토큰입니다.");
} catch (IllegalArgumentException e){
log.info("access 잘못된 값이 입력되었습니다.");
}
return false;
}
// 헤더에 있는 토큰에서 "Bearer "를 뺀 토큰 유효 검증 (refresh Token 검증)
public boolean validateRefreshToken(String token){
try {
Jwts.parserBuilder().setSigningKey(JWTProperties.REFRESH_KEY).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e){
log.info("잘못된 JWT refresh 토큰 형식입니다.");
} catch (ExpiredJwtException e){
log.info("JWT refresh 토큰의 유효 기간이 지났습니다.");
log.info("재로그인 요청바람");
} catch (UnsupportedJwtException e){
log.info("지원하지 않는 JWT refresh 토큰입니다.");
} catch (IllegalArgumentException e){
log.info("refresh 잘못된 값이 입력되었습니다.");
}
return false;
}
// JWT 토큰을 해독하여 Claim에 "loginId"의 값을 가져온다.
public String getLoginId(String accessToken){
String loginId = parseClaims(accessToken).get("loginId", String.class);
return loginId;
}
// access 토큰에서 가져온 Claims안에 memberId 추출하기
public Long getMemberId(String accessToken){
return parseClaims(accessToken).get("memberId", Long.class);
}
// JWT access토큰에서 Claims 추출하기
public Claims parseClaims(String accessToken){
try {
return Jwts.parserBuilder().setSigningKey(JWTProperties.ACCESS_KEY).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e){
return e.getClaims();
}
}
}
--
Refresh Token 저장하기
--
RefreshToken.java
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String loginId;
private String token;
public RefreshToken(String loginId, String token){
this.loginId = loginId;
this.token = token;
}
public void updateToken(String token){
this.token = token;
}
}
해당 Refresh Token의 주인이 누구인지 판단하기 위해
모든 회원, 관리자를 통틀어 고유한 값인 loginId를 추가로 같이 저장해주었다.
Refresh Token은 Redis 같은 속도 빠른 메모리에 저장하는 것이 좋다.
다만 1 ~ 2주 전부터 Redis를 무료로 사용하는 방법이 막혀서
방법을 찾거나 Redis를 사용하기 전까지 임시로 DB를 사용하기로 했다.
--
로그인 구현하기 [ 인증 (Authentication) ]
--
Controller
@PostMapping("/login")
public void memberLogin(@RequestBody MemberDto.LoginRequest request, HttpServletResponse response){
memberService.memberLogin(request, response);
}
Service
@Transactional
public void memberLogin(MemberDto.LoginRequest request, HttpServletResponse response) {
Member member = memberRepository.findByLoginId(request.getLoginId()).orElseThrow(IllegalArgumentException::new);
if(!bCryptPasswordEncoder.matches(request.getLoginPassword(), member.getLoginPassword())){
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
// Access Token과 Refresh Token 생성
String accessToken = jwtFunction.createAccessToken(member);
String refreshToken = jwtFunction.createRefreshToken(member);
// 이전에 DB에 저장된 Refresh Token이 있는지 확인
RefreshToken checkRefreshTooken = refreshTokenRepository.findByLoginId(member.getLoginId()).orElse(null);
// 만약 있다면 새로 발급받은 Refresh Token으로 교체, 없다면 새로 등록
if(checkRefreshTooken != null){
checkRefreshTooken.updateToken(refreshToken);
} else {
RefreshToken refreshTokenEntity = new RefreshToken(member.getLoginId(), refreshToken);
refreshTokenRepository.save(refreshTokenEntity);
}
// 응답할 Http 헤더에 토큰을 담음
response.addHeader(JWTProperties.HEADER_STRING, accessToken);
response.addHeader(JWTProperties.REFRESH_STRING, refreshToken);
member.loginStatus();
}
우선 요청한 아이디와 비밀번호가 존재한지 확인을 한다.
이 때 해당 회원이 존재한다면 Access Token과 Refresh Token을 생성하고
이전에 DB에 저장했던 Refresh Token이 있는지 확인 후 있다면 새로 발급한 토큰으로 교체, 없다면 새로 저장한다.
그리고 해당 토큰들을 다시 클라이언트에게 응답하기 위해 Header에 담아서 응답한다.
--
Access Token에 담긴 정보를 가지고 회원(관리자) 확인하기
--
Access Token에 담긴 정보를 담을 DTO 만들기
ServerCheckDto.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class ServerCheckDto {
private long memberAndAdminId;
private String loginId;
private String loginPassword;
private String roles;
public ServerCheckDto(Member member){
memberAndAdminId = member.getMemberId();
loginId = member.getLoginId();
loginPassword = member.getLoginPassword();
roles = member.getRoles();
}
public ServerCheckDto(Admin admin){
memberAndAdminId = admin.getAdminId();
loginId = admin.getLoginId();
loginPassword = admin.getLoginPassword();
roles = admin.getRoles();
}
public List<String> getRoleList(){
if(this.roles.length()>0){
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
}
role 필드 즉, 권한을 저장하는 필드는
여러 개의 권한을 저장할 때 ","로 구분하여 문자열로 저장을 하는 방법을 사용했다.
그래서 권한을 꺼내올 때 ","를 기준으로 split하여 리스트로 꺼내오는 getRoleList() 메서드를 추가했다.
권한을 문자열 대신 Enum 클래스를 사용해서 상수로 하는 방법도 있다.
기존 Spring Security의 UserDetails 인스턴스 구현하기
CustomUserDetails.java
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final ServerCheckDto serverCheckDto;
//------------------------------------------------
// 사용자의 권한 목록을 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
serverCheckDto.getRoleList().forEach(r -> {
authorities.add(() -> r);
});
return authorities;
}
//--------------------------------------------------
@Override
public String getPassword() {
return serverCheckDto.getLoginPassword();
}
@Override
public String getUsername() {
return serverCheckDto.getLoginId();
}
//----------------------------------------------------
// 사용자 계정의 만료 여부
@Override
public boolean isAccountNonExpired() {
return true;
}
// 사용자 계정의 잠금 여부
@Override
public boolean isAccountNonLocked() {
return true;
}
// 사용자 계정의 자격 증명의 만료 여부
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 사용자 계정의 활성화 여부
@Override
public boolean isEnabled() {
return true;
}
}
isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled()를
모두 true로 반환하도록 하면
계정이 만료되지 않았고 잠기지도 않았으며 자격 증명이 만료되지 않고 활성화되어 있다는 것을
가정하도록 한다.
기존 Spring Security의 UserDetailsService 인스턴스 구현하기
CustomUserDetailsService.java
@Service
@Transactional
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final AdminRepository adminRepository;
@Override
public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
Member member = memberRepository.findByLoginId(loginId).orElse(null);
Admin admin = adminRepository.findByLoginId(loginId).orElse(null);
ServerCheckDto serverCheckDto;
if(member == null && admin != null){
serverCheckDto = new ServerCheckDto(admin);
} else if(member != null && admin == null){
serverCheckDto = new ServerCheckDto(member);
} else {
throw new IllegalArgumentException("존재하지 않는 아이디 입니다.");
}
return new CustomUserDetails(serverCheckDto);
}
}
--
토큰을 검사하는 Filter 추가하기
--
Spring Security에서 제공하는 Filter는 20개가 넘는다.
기존에 존재하는 Filter에 내가 원하는 동작을 하는 Filter를 찾아서 해당 Filter 안에 추가로 로직을 작성하는 방법도 있지만
새로운 Filter를 만들어서 체인에 추가하는 방법을 사용했다.
CustomJwtFilter.java
@Slf4j
@RequiredArgsConstructor
public class CustomJwtFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
private final JwtFunction jwtFunction;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 헤더에 "Authorization"와 "Refresh_Token"에 담긴 값을 가져온다. (토큰)
String header_access = request.getHeader(JWTProperties.HEADER_STRING);
String header_refresh = request.getHeader(JWTProperties.REFRESH_STRING);
// 헤더에 토큰이 존재하는지 검사 (Authorization에 값이 있고, 그 값이 "Bearer "로 시작하는가? + Refresh_Token에는 값이 없는가) = access 토큰만 요청 받았는가?
if(header_access != null && header_access.startsWith(JWTProperties.TOKEN_PREFIX) && header_refresh == null){
// 앞에 "Bearer " 문자열을 빼고 온전히 토큰만 가져온다.
String jwtToken = header_access.replace("Bearer ", "");
// JWT 토큰 유효성 검사
if(jwtFunction.validateAccessToken(jwtToken)){
// JWT 토큰을 복호화해서 Claim에 있는 loginId를 가져온다.
String loginId = jwtFunction.getLoginId(jwtToken);
// Member에 토큰의 정보에 알맞는 Member가 있으면 userDetails 생성
UserDetails userDetails = customUserDetailsService.loadUserByUsername(loginId);
// 토큰의 회원 id와 URI의 id가 일치한지 검증
String str = request.getRequestURI();
log.info("{}", str);
if(str.startsWith("/member")){
// 토큰에서 회원 id 추출
Long memberId = jwtFunction.getMemberId(jwtToken);
// uri에서 id 추출과 동시에 id 비교
getIdFromRequest(str, memberId);
}
// 잘 가져왔나 확인
if(userDetails != null){
// 사용자 식별자(userDetails)와 접근권한 인증용 토큰 생성 (JWT 토큰 아님!!) [ 정확히는 사용자의 인증 정보를 가지고 있는 객체 생성 ]
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// 스프링 시큐리티에서 현재 사용자의 인증 정보를 설정하는 코드로
// 사용자가 인증되었음을 시스템에게 알려준다. (인증된 상태로 세션을 유지 = 시큐리티가 현재 사용자를 식별하고 보호가능)
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}
// access 토큰이 만료되어 refresh 토큰을 재요청한 상태 / 헤더에 access와 refresh 토큰 모두 가지고 있는가?
if(header_refresh != null && header_refresh.startsWith(JWTProperties.TOKEN_PREFIX) && header_access == null){
// 앞에 "Bearer " 문자열을 빼고 온전히 토큰만 가져온다.
String jwtToken = header_refresh.replace("Bearer ", "");
// JWT 토큰 유효성 검사
jwtFunction.validateRefreshToken(jwtToken);
}
filterChain.doFilter(request, response);
}
}
평소 요청에는 Authorization 즉, Access Token만 헤더에 담아서 요청하고
Access Token이 만료되어 재 요청할 때는 Refresh Token만 헤더에 담아서 요청한다는 가정하에
구현한 로직으로 Access Token만 담아져서 왔을 때와 Refresh Token만 담아져서 요청오는 두 가지 로직으로 나눴다.
OncePerRequestFilter란?
서버 요청시 딱 한 번만 실행하는 것을 보장하는 필터이다.
이 말은 한 번 요청 했을 때 해당 필터는 무조건 한 번만 실행하고 그 이상 이하 실행하지 않는다.
일반 Filter나 GenericFilterBean으로 필터를 구현한 필터는
두 번 실행되는 현상이 발생할 수도 있다.
--
CORS (Cross-Origin Resource Sharing) 정의하기
--
CORS란?
다른 도메인의 리소스 요청을 허용하도록 하는 메커니즘을 정의하는 곳이다.
예시로 React와 연동할 때 서로 포트번호가 다르기 때문에 다른 포트 번호에서 요청하는 것을 허용하거나
URI 접근에 대해 허용 여부 등 서버에 요청하는 것에 대해 정의한다.
CorsConfig.java
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// 내 서버가 응답할 때 JSON을 js에서 처리할 수 있게 할지 설정
config.setAllowCredentials(true);
// 모든 ip에 응답을 허용
config.addAllowedOriginPattern("*");
// 모든 header에 응답을 허용
config.addAllowedHeader("*");
// 모든 http Method (post, get 등등)의 요청을 허용
config.addAllowedMethod("*");
// /api/**로 들어오는 url에 대해서는 config대로 정의함
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
--
Spring Security를 사용하여 보안 구성하기 ( SecurityContext )
--
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
private final CustomUserDetailsService customUserDetailsService;
private final JwtFunction jwtFunction;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
sharedObject.userDetailsService(this.customUserDetailsService);
AuthenticationManager authenticationManager = sharedObject.build();
http.authenticationManager(authenticationManager);
// CSRF, CORS 정의 (CSRF 보호 비활성화, CORS 필터 추가)
http.csrf(cs -> cs.disable());
http.addFilter(corsFilter);
// 세션 관리 상태가 없는 Stateless 방식으로 설정 (세션 사용하지 않음)
http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 폼 기반 로그인과 HTTP 기본 인증을 모두 비활성화
http.formLogin(f -> f.disable());
http.httpBasic(AbstractHttpConfigurer::disable);
// CustomJwtFilter를 UsernamePasswordAuthenticationFilter필터 실행 전에 먼저 실행하도록 해당 필터 앞에 추가
http.addFilterBefore(new CustomJwtFilter(customUserDetailsService, jwtFunction), UsernamePasswordAuthenticationFilter.class);
// 각 요청에 대한 접근 권한을 설정
http.authorizeHttpRequests(authorize -> {
authorize
// 공통 관리자 권한
.requestMatchers(new AntPathRequestMatcher("/admin/**/view"))
.hasAnyRole("REPRESENTATIVE", "OPERATOR", "CUSTOMER", "ADMIN")
// 대표 권한
.requestMatchers("/adminIdCheck",
"/adminJoin",
"/admin/admin/List",
"/admin/admin/**",
"/admin/grade/**")
.hasAnyRole("REPRESENTATIVE")
// 운영 관리자 권한
.requestMatchers("/admin/order/**",
"/admin/item/**",
"/admin/category/**")
.hasAnyRole("OPERATOR", "REPRESENTATIVE")
// 고객 관리자 권한
.requestMatchers("/admin/*/announcement/**",
"/admin/*/QAComment",
"/admin/*/comment")
.hasAnyRole("CUSTOMER", "REPRESENTATIVE")
// 회원 권한
.requestMatchers("/member/**")
.hasAnyRole("USER")
// 이 외의 요청들은 모두 허용
.anyRequest().permitAll();}
);
return http.build();
}
}
--
'Spring Boot' 카테고리의 다른 글
전역으로 예외 처리 통일 시키는 방법 (0) | 2024.04.24 |
---|---|
결제 API 포트원 (구 아임포트)로 결제 시스템 구현하기 (0) | 2024.04.23 |
Spring Security에 대해서 (0) | 2024.04.18 |
자동으로 데이터의 생성일, 수정일, 생성자, 수정자 저장하기 (Auditing) (0) | 2024.01.22 |
상태 코드 반환하기 (0) | 2024.01.18 |