DB, SpringSecurity, 회원가입은 아래 글로....
2024.06.21 - [BE/Java] - Springboot3 + Swagger + Jwt (3)
Springboot3 + Swagger + Jwt (3)
Swagger 초기 셋팅2024.06.20 - [BE/Java] - Springboot3 + Swagger + Jwt (2) Springboot3 + Swagger + Jwt (2)프로젝트 진행 이유 및 개발 환경2024.06.20 - [BE/Java] - Springboot3 + Swagger + Jwt (1) Swagger란?REST API 개발을 진행하
tistory.slowtuttle.co.kr
진행할 내용
1. JWT 적용
(아마 직접적으로는 처음 적용해보는 내용이기 때문에 굉장히 많은 삽질을 하게 될 것 같다아아아~~)
2. 로그인 구현
3. 로그인 및 토큰 발급 테스트 (우선 Access Token만 진행)
4. Swagger 적용
중간중간 추가/변경된 내용이 많기 때문에 끝까지 흐름을 따라오면 잘 될 것이다.
또한 파일들의 경로는 사용자들마다 다르기때문에 그에 맞는 import 경로를 설정해줘야한다.
jwt 간략한 설명글은 여기로..
2024.06.10 - [BE/Java] - JWT(Json Web Token) 이란?
JWT(Json Web Token) 이란?
구성 이유추후 프로젝트에서 로그인 기능에서 적용하기 위하여 사전 공부 JWT 란?더보기Json Web Token 의 축약어로 Json 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token.
tistory.slowtuttle.co.kr
해당 글은 JWT 적용 및 테스트 과정들이 들어가있어서 매우 깁니다!!!!!
JWT 적용과정
build.gradle
//Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
//json 응답을 위한 라이브러리
implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'
application.yml
- expTime : 토큰 만료 시간
- secret : 서명키를 Base64로 암호화
o 해당 값을 알아내면 토큰 생성 및 위변조가 가능하기 때문에 보안을 위한 설정
o 최소 512bits 이상의 값을 설정하기를 권장
o Base64 디코딩 사이트 : https://www.base64decode.org/ko/
jwt:
expTime: 86400000 #1일
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
JwtUtil.java
- 토큰 인증, 발급, 인가, Claim 추가
package org.jjuni.swaggerjwt.auth.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.jjuni.swaggerjwt.member.entity.Member;
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.stereotype.Component;
import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;
/**
* [JWT 관련 메서드를 제공하는 클래스]
*/
@Slf4j
@Component
public class JwtUtil {
private final Key key;
private final long accessTokenExpTime;
public JwtUtil(
@Value("${jwt.secret}") String secretKey,
@Value("${jwt.expTime}") long accessTokenExpTime
) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpTime = accessTokenExpTime;
}
/**
* Access Token 생성
* @param member
* @return Access Token String
*/
public String createAccessToken(Member member) {
return createToken(member, accessTokenExpTime);
}
/**
* JWT 생성
* @param member
* @param expireTime
* @return JWT String
*/
private String createToken(Member member, long expireTime) {
Claims claims = Jwts.claims();
claims.put("id", member.getId()); // 내부적으로 처리되는 ID (Long)
claims.put("userId", member.getUserId()); // 사용자가 사용하는 userId (String)
claims.put("email", member.getEmail());
claims.put("role", member.getRole());
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(expireTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Token에서 ID 추출
* @param token
* @return ID (Long)
*/
public Long getId(String token) {
return parseClaims(token).get("id", Long.class);
}
/**
* Token에서 User ID 추출
* @param token
* @return User ID (String)
*/
public String getUserId(String token) {
return parseClaims(token).get("userId", String.class);
}
/**
* JWT 검증
* @param token
* @return IsValidate
*/
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 Claims 추출
* @param accessToken
* @return JWT Claims
*/
public Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtAuthorizationFilter.java
package org.jjuni.swaggerjwt.auth.jwt;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.coyote.BadRequestException;
import org.json.simple.JSONObject;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
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.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
/**
* [지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인]
*
* @author lee
* @fileName JwtAuthorizationFilter
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/swagger-ui/**",
"/swagger-resources/**",
"/swagger.html",
"/api-docs/**",
"/api/v1/auth/sign-in",
"/api/v1/auth/sign-up",
"/console/**" // H2
);
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* JWT 토큰 검증 필터 수행
*/
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
throws IOException, ServletException {
// 요청 url 추출
String requestURI = request.getRequestURI();
boolean isExcludedPath = EXCLUDE_PATHS.stream()
.anyMatch(excludePath -> pathMatcher.match(excludePath, requestURI));
if (isExcludedPath) {
chain.doFilter(request, response);
return;
}
// 2. OPTIONS 요청일 경우 => 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
chain.doFilter(request, response);
return;
}
// [STEP1] Client에서 API를 요청할때 Header를 확인합니다.
String authorizationHeader = request.getHeader("Authorization");
logger.debug("[+] header Check: " + authorizationHeader);
try {
// [STEP2-1] Header 내에 토큰이 존재하는 경우
if (authorizationHeader != null && !authorizationHeader.equalsIgnoreCase("")) {
// [STEP2] Header 내에 토큰을 추출
String accessToken = authorizationHeader.substring(7);
// [STEP3] 추출한 토큰이 유효한지 여부를 체크
if (jwtUtil.validateToken(accessToken)) {
// [STEP4] 토큰을 기반으로 사용자 아이디를 반환 받는 메서드
String userId = jwtUtil.getUserId(accessToken);
logger.debug("[+] user id Check: " + userId);
// [STEP5] 사용자 아이디가 존재하는지 여부 체크
if (userId != null) {
//[STEP6] 사용자 정보 조회 후 security context 등록 (userId로 체크)
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} else {
throw new UsernameNotFoundException("해당하는 사용자가 없습니다.");
}
// 토큰이 유효하지 않은 경우
} else {
throw new BadRequestException("토큰 정보가 유효하지 않습니다.");
}
}
// [STEP2-1] 토큰이 존재하지 않는 경우
else {
throw new JwtException("토큰 정보가 누락되어있습니다.");
}
} catch (Exception e) {
// Token 내에 Exception이 발생 하였을 경우 => 클라이언트에 응답값을 반환하고 종료합니다.
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
JSONObject jsonObject = jsonResponseWrapper(e);
printWriter.print(jsonObject);
printWriter.flush();
printWriter.close();
}
}
/**
* 토큰 관련 Exception 발생 시 예외 응답값 구성
*
* @param e Exception
* @return JSONObject
*/
private JSONObject jsonResponseWrapper(Exception e) {
String resultMsg = "";
// 일치하는 사용자 정보가 없는경우
if (e instanceof UsernameNotFoundException) {
resultMsg = "Not Found User";
}
// JWT 토큰 만료
else if (e instanceof ExpiredJwtException) {
resultMsg = "TOKEN Expired";
}
// JWT 허용된 토큰이 아님
else if (e != null) {
resultMsg = "TOKEN SignatureException Login";
}
// JWT 토큰내에서 오류 발생 시
else if (e instanceof JwtException) {
resultMsg = "TOKEN Parsing JwtException";
}
// 이외 JTW 토큰내에서 오류 발생
else {
resultMsg = "OTHER TOKEN ERROR";
}
HashMap<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMsg);
jsonMap.put("reason", e.getMessage());
JSONObject jsonObject = new JSONObject(jsonMap);
logger.error(resultMsg, e);
return jsonObject;
}
}
SecurityConfig.java
- 예외 경로 추가 : excludePath
- httpSecurity 내용 변경
o JwtAuthorizationFilter 추가
o excludePath 변경
package org.jjuni.swaggerjwt.config;
import lombok.RequiredArgsConstructor;
import org.jjuni.swaggerjwt.auth.jwt.JwtAuthorizationFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
~~~~~~ DB 클래스 이름 전과 동일 ~~~~
// swagger, 로그인 예외 경로 설정 추가
private static final String[] excludePath = {
"/",
"/swagger-ui/**",
"/swagger.html",
"/v3/api-docs/**",
"/api-docs/**",
"/swagger-resource/**",
"/api/v1/auth/sign-in",
"/api/v1/auth/sign-up"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, JwtAuthorizationFilter jwtAuthorizationFilter)
throws Exception {
httpSecurity
.csrf(csrf -> csrf.disable()) // api 서버로 사용하기 때문에 csrf 해제 (jwt로 대체)
.httpBasic(http -> http.disable()) // 로그인 인증창이 뜨지 않게 비활성화
.formLogin(form -> form.disable()) // form 로그인 해제
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // jSessionId 사용 거부
// 인증, 권한 필터 설정
.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class) // jwt 필터 추가
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers(excludePath).permitAll()
.anyRequest().authenticated());
~~~~~ H2 설정 전과 동일 ~~~~~
return httpSecurity.getOrBuild();
}
// 예외 경로 패턴 추가
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(excludePath);
}
}
필터 방식을 보면 UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 이렇게 두가지를 많이 사용하는 것 같은데 문득 왜 해당 클래스를 상속받아서 쓰는지 궁금하였다. 같은 생각으로 궁금증을 가진 사람은 "여기" 로 이동해서 확인할 수 있다.
CustomUserDetailService.java
- 사용자 조회 로직을 커스텀해서 사용하기 위함
- @Transactional(readOnly = True) 옵션을 주는 이유?
- CRUD중 R 작업만 담당하며 처리 속도가 증가하기 때문이다.
- 추후에 다중화 구조의 DB에서 한쪽은 Read 전용으로만 설정하여 기존 서비스에 가는 부담을 줄일 수 있다.
package org.jjuni.swaggerjwt.member.service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import org.jjuni.swaggerjwt.member.entity.Member;
import org.jjuni.swaggerjwt.member.repository.MemberRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public User loadUserByUsername(String userId) throws UsernameNotFoundException {
// userId로 사용자 정보를 찾기
Member user = memberRepository.findByUserId(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found with id: " + userId));
// User 객체 생성
return new User(user.getUserId(), "", List.of(new SimpleGrantedAuthority(user.getRole().toString())));
}
}
SwaggerConfig.java
- swagger에서 회원가입, 로그인 을 제외한 모든 API에 JWT를 적용하기 위함
@Configuration
@RequiredArgsConstructor
public class SwaggerConfig {
// 보안 인증 key 설정
private static final String SECURITY_SCHEME_NAME = "authorization";
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
// jwt 관련 설정
.components(new Components()
// JWT 사용s
.addSecuritySchemes(SECURITY_SCHEME_NAME, new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
// swagger 이름 설정
.info(new Info()
.title("Springboot+Swagger+JWT")
.version("1.0")
.description("초기 구축 셋팅 과정을 기록하는 swagger 입니다."));
}
}
application.yml
- api-docs 문서 경로 변경 : /api/v1 => /api-docs
##############################################
### swagger
##############################################
springdoc:
api-docs:
path: /api-docs # api 문서 정리 (내부 api url 과 동일할 필요가 없음)
groups:
enabled: true
swagger-ui:
path: /swagger.html
enabled: true
groups-order: ASC
tags-sorter: alpha
operations-sorter: alpha
display-request-duration: true
doc-expansion: none
cache:
disabled: true
model-and-view-allowed: true
default-produces-media-type: application/json
default-consumes-media-type: application/json
회원정보 및 관련 로직 수정
Member.java
- jwt 사용자 권한(Role) 추가
package org.jjuni.swaggerjwt.member.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.jjuni.swaggerjwt.auth.enums.RoleType;
import org.springframework.data.annotation.CreatedDate;
import java.time.LocalDateTime;
@Entity
@Builder
@Table(name = "tb_user")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO) // jpa 자동생성
private Long id;
@Comment("아이디")
@Column(columnDefinition = "varchar(50)", nullable = false)
private String userId;
@Comment("비밀번호")
@Column(nullable = false)
private String password;
@Comment("이름")
@Column(columnDefinition = "varchar(10)", nullable = false)
private String name;
@Comment("전화번호")
@Column(columnDefinition = "varchar(14)")
private String phoneNum;
@Comment("이메일")
@Column(columnDefinition = "varchar(50)", nullable = false)
private String email;
// JWT 사용자 권한 추가
@Comment("권한")
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "varchar(50)", nullable = false)
private RoleType role;
@Comment("생성일")
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime created;
@Comment("수정일")
@UpdateTimestamp
@Column(updatable = false)
private LocalDateTime updated;
}
RoleType.java
- 사용자 권한 enum 설정
package org.jjuni.swaggerjwt.auth.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum RoleType {
ADMIN, USER
}
SignUpRequeset.java
- 회원가입 시 사용자 권한 추가 및 defaultValue에서 example로 변경
package org.jjuni.swaggerjwt.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;
import org.jjuni.swaggerjwt.auth.enums.RoleType;
import org.jjuni.swaggerjwt.member.entity.Member;
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SignUpRequest {
@Size(min = 2, max = 50)
@Schema(description = "사용자ID", example = "leejj9999")
@NotBlank(message = "아이디를 입력해주세요")
private String userId;
@Size(min = 9, max = 30)
@Schema(description = "비밀번호", example = "test1234!@#$")
@NotBlank(message = "비밀번호를 입력해주세요")
private String password;
@Size(min = 2, max = 30)
@Schema(description = "이름", example = "테스트")
@NotBlank(message = "이름을 입력해주세요")
private String name;
@Size(min = 13, max = 14)
@Schema(description = "연락처", example = "010-1234-1234")
@NotBlank(message = "연락처를 입력해주세요")
private String phoneNum;
@Size(min = 11, max = 20)
@Schema(description = "이메일", example = "test9999@naver.com")
@NotBlank(message = "이메일을 입력해주세요")
private String email;
public Member toEntity() {
return Member.builder()
.userId(userId)
.password(password)
.name(name)
.phoneNum(phoneNum)
.email(email)
.role(RoleType.USER) // 사용자 권한 추가(default USER)
.build();
}
}
SignUpResponse.java
- 회원가입한 사용자 정보 응답 생성
package org.jjuni.swaggerjwt.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.jjuni.swaggerjwt.member.entity.Member;
@Data
@AllArgsConstructor
public class SignUpResponse {
@Schema(description = "회원 고유 id", example = "123")
private Long id;
@Schema(description = "회원 id", example = "test1111")
private String userId;
@Schema(description = "연락처", example = "010-1234-1234")
private String phoneNum;
@Schema(description = "이메일", example = "test1111@naver.com")
private String email;
@Schema(description = "회원 이름", example = "이정준")
private String name;
@Schema(description = "회원 유형", example = "USER")
private org.jjuni.swaggerjwt.auth.enums.RoleType RoleType;
public static SignUpResponse toDto(Member member) {
return new SignUpResponse(member.getId(),
member.getUserId(),
member.getPhoneNum(),
member.getEmail(),
member.getName(),
member.getRole());
}
}
SignInRequest.java
- 로그인 요청 dto 생성
package org.jjuni.swaggerjwt.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 로그인 요청 Dto
*/
@Data
public class SignInRequest {
@Size(min = 2, max = 50)
@Schema(description = "사용자ID", example = "leejj9999")
@NotBlank(message = "아이디를 입력해주세요")
private String userId;
@Size(min = 9, max = 30)
@Schema(description = "비밀번호", example = "test1234!@#$")
@NotBlank(message = "비밀번호를 입력해주세요")
private String password;
}
SignInResponse.java
- 로그인 응답 dto 생성
/**
* 로그인 응답 Dto
*/
@Data
@AllArgsConstructor
public class SignInResponse {
@Schema(description = "회원 이름", example = "이정준")
private String name;
@Schema(description = "회원 유형", example = "USER")
private RoleType RoleType;
private String accessToken;
private String refreshToken;
}
AuthController.java (controller)
- 기존 SignUpResponse가 없었는데 추가됨!
package org.jjuni.swaggerjwt.auth.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.jjuni.swaggerjwt.auth.dto.SignInRequest;
import org.jjuni.swaggerjwt.auth.dto.SignInResponse;
import org.jjuni.swaggerjwt.auth.dto.SignUpRequest;
import org.jjuni.swaggerjwt.auth.dto.SignUpResponse;
import org.jjuni.swaggerjwt.auth.service.AuthService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Auth", description = "회원가입, 로그인, 로그아웃 인증처리 API")
@RestController
@RequestMapping("/api/v1/auth")
@AllArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 사용자 회원가입
*
* @param request
* @return SignUpResponse
*/
@Operation(summary = "회원가입", description = "신규 사용자 회원가입")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User Create Success")
})
@ResponseBody
@PostMapping("sign-up")
public ResponseEntity<?> signUp(@Validated @RequestBody SignUpRequest request) throws Exception {
// 회원가입 사용자 정보 반환
SignUpResponse response = authService.signUp(request);
return new ResponseEntity<>(response, HttpStatus.OK);
}
/**
* 사용자 로그인
*
* - 우선 Access Token만 발급
* @param request
* @return
*/
@Operation(summary = "로그인", description = "사용자 로그인 (jwt 토큰 발급)")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User Login Success")
})
@ResponseBody
@PostMapping("sign-in")
public ResponseEntity<?> signIn(@Validated @RequestBody SignInRequest request) {
SignInResponse response = authService.signIn(request);
return new ResponseEntity<>(response, HttpStatus.OK);
}
}
AuthService.java (service)
- 회원가입 로직을 변경하였다.
package org.jjuni.swaggerjwt.auth.service;
import jakarta.transaction.Transactional;
import jakarta.xml.bind.ValidationException;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jjuni.swaggerjwt.auth.dto.SignInRequest;
import org.jjuni.swaggerjwt.auth.dto.SignInResponse;
import org.jjuni.swaggerjwt.auth.dto.SignUpRequest;
import org.jjuni.swaggerjwt.auth.dto.SignUpResponse;
import org.jjuni.swaggerjwt.auth.jwt.JwtUtil;
import org.jjuni.swaggerjwt.member.entity.Member;
import org.jjuni.swaggerjwt.member.repository.MemberRepository;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Slf4j
@Service
@AllArgsConstructor
public class AuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder encoder;
private final JwtUtil jwtUtil; // jwt 설정 추가
/**
* 사용자 회원가입
*
* @param req
*/
@Transactional
public SignUpResponse signUp(SignUpRequest req) throws Exception {
// id로 사용자 정보 조회
memberRepository.findByUserId(req.getUserId())
.orElseThrow(() -> new ValidationException("이미 회원가입한 고객입니다."));
Member newMember = req.toEntity();
try {
newMember.setPassword(encoder.encode(req.getPassword()));
} catch (Exception e) {
throw new Exception(e);
}
// 사용자 등록
try {
memberRepository.save(newMember);
} catch (Exception e) {
throw new Exception(e);
}
return SignUpResponse.toDto(newMember);
}
/**
* 사용자 로그인
*
* @param req
*/
@Transactional
public SignInResponse signIn(SignInRequest req) {
// id로 사용자 정보 조회
Optional<Member> memberInfo = memberRepository.findByUserId(req.getUserId());
if (memberInfo.get() == null) { // 사용자가 존재하지 않습니다.
throw new UsernameNotFoundException("존재하지 않는 사용자 입니다.");
}
// 사용자 비밀번호 비교 (뒤에가 암호화 되지 않은 값이 와야함!!)
if (encoder.matches(memberInfo.get().getPassword(),req.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
// access token 발급
return new SignInResponse(memberInfo.get().getName(), memberInfo.get().getRole(), jwtUtil.createAccessToken(memberInfo.get()), "");
}
}
MemberRepository.java
- Optional로 값을 전달 받아서 CustomUserDetailService에서도 사용
package org.jjuni.swaggerjwt.member.repository;
import org.jjuni.swaggerjwt.member.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUserId(String userId);
}
TestController.java
- jwtAuthorizationFilter가 제대로 작동하고 있는지 확인하기 위한 단순한 controller
package org.jjuni.swaggerjwt.test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/test")
public class TestController {
@GetMapping("test")
public String test() {
return "요청 되는지 확인하는 테스트 url";
}
}
JWT 필터 적용 확인 테스트
- swagger 접속 후 TestController로 요청하였을때 응답이 요청되면 안됨!
요청

결과

필터가 성공적으로 적용되었음!!!! (필터 경로 때문에 삽직을 하루정도 해서 너무 행복한상태.. 😁)
회원가입 테스트
- 이전과는 다르게 회원가입하는 경우 Role 이 추가되었기 때문에 회원가입을 다시 해줘야함!
요청

응답

로그인 테스트
요청

응답

JWT 인증 후 테스트
JWT 인증
- swagger 상단에 Authorize 자물쇠 버튼을 눌러서 로그인 결과로 받은 Access Token 인증

TestController 재요청
요청 및 응답

성공적으로 호출 완료~!!
느낀점
1. Security와, jwtAuthorizationFilter 설정때문에 골치가 좀 아팠었다....
Swagger를 처음 사용해보다보니 api-docs 의 경로와 실제 요청하는 api 경로가 동일해야지만 작동하는 줄 알고 둘 다 /api/v1 으로 설정을 했었다.
그러다보니 실제 요청 api 주소가 /api/v1 이었고, api 문서도 /api/v1 으로 설정되어 있다보니, filter를 걸면 swagger를 할 수 없고... filter를 해제하니 전부 다 해제되고.... api 문서 경로를 바꿔서 해결하였다...
(검색해도 안나오는건 나는 얼마나 모르고 있던 것인가... 하는...)
2. 검색을 하다보니 다들 UserDetailService를 상속받아서 구현을 하던데, 굳이 그럴 필요가 있을까? 하는 생각이 들었었다. 그런데 막상 내가 구성을 해보니 loadUserByUsername() 부분에서 기본 pk로 조회하는 방식이 아니다보니 내가 원하는 FLOW와는 다르게 흘러가게 되었고 결국 나도 상속받아서 작업을 진행하게 되었다.
3. SecurityContextHolder 에 등록하기 위해서 UsernamePasswordAuthenticationToken 가 필요하며,
UsernamePasswordAuthenticationToken 객체를 만들기 위해서 UserDetails 객체가 필요하다.
SecurityContextHolder > UsernamePasswordAuthenticationToken > UserDetails
SpringSecurity, JWT, Swagger 조합을 처음 적용해보다보니 너무 많은 삽질을 했었다....
앞으로 refreshToken 및 코드를 다듬는 과정을 진행해야하니 힘들지만 많은 부분을 배워나갈 수 있을 것 같다...! 힘내자!
Refs
작업중인 github : https://github.com/yellowbim/swaggerJwt
GitHub - yellowbim/swaggerJwt: swagger Jwt 생성
swagger Jwt 생성. Contribute to yellowbim/swaggerJwt development by creating an account on GitHub.
github.com
기본 Jwt 로직 참고 : https://sjh9708.tistory.com/170#google_vignette
[Spring Boot] Spring Security : JWT Auth (SpringBoot 3 버전)
작년 말, Spring 2점대 버전의 지원이 공식 중단되면서, 이제 웬만하면 Spring 3 버전대를 사용할 것을 Spring 진영에서 권장하고 있다.그 중 Spring Security의 경우 변화한 내용이 조금 있는 편이라 이 참
sjh9708.tistory.com
시크릿키 암호화 및 보안에 신경써야한는 이유 : https://leffept.tistory.com/450
[JWT]JWT 사용시 주의할 점 & 문제점
안녕하세요, 오늘은 지난번 포스팅에 이어 JWT를 무턱대고 사용할 때 생기는 문제점과 주의해야할 점들에 대해서 이야기 해보겠습니다. JWT 사용시 주의할 점 시크릿 키의 설정 JWT의 가장 핵심적
leffept.tistory.com
Swagger 경로 설정 관련 이슈 : https://blog.naver.com/seek316/223349824088
[Java] Spring Boot - springdoc openapi 사용하기
springdoc-openapi란? springdoc-openapi 라이브러리는 Spring Boot 프로젝트를 사용하여 API 문서 생...
blog.naver.com
@Transactional 에 readOnly를 붙이는 이유 : https://hungseong.tistory.com/74
@Transactional(readOnly = true)를 왜 붙여야 하나요
스프링으로 개발하면서 필연적으로 사용하게 되는 @Transactional. 우리는 스프링의 AOP를 통해 @Transactional 어노테이션만으로 손쉽게 Service Layer에서 트랜잭션을 걸 수 있다. 일반적으로, 조회용 메서
hungseong.tistory.com
'BE > Java' 카테고리의 다른 글
| String, StringBuffer, StringBuilder 의 차이 (0) | 2024.07.11 |
|---|---|
| Springboot3 + Swagger + Jwt (5) (0) | 2024.07.03 |
| Security 필터 종류? (1) | 2024.06.25 |
| Springboot3 + SpringSecurity + H2 403 (0) | 2024.06.24 |
| Springboot3 + Swagger + Jwt (3) (0) | 2024.06.21 |