도찐개찐
Spring Security + JWT 회원가입, 로그인 구현 본문
작성 소스 GitHub 경로
인증(Authentication)과 권한(Authorization)
먼저 스프링 시큐리티에서 애플리케이션 보안을 구성하는 두 가지 영역에 대해 간단하게 설명해 보도록 하겠습니다. 이 두 영역은 사실상 스프링 시큐리티의 핵심이라고 볼 수 있습니다.
https://dev.to/caffiendkitten/authentication-vs-authorization-25lc
인증(Authentication)은 보호된 리소스에 접근하는 대상, 즉 사용자에게 적절한 접근 권한이 있는지 확인하는 일련의 과정을 의미합니다. 이 때 보호된 리소스에 접근하는 대상(사용자)을 접근 주체(Principal)이라고 합니다. 권한(Authorization)은 인증절차가 끝난 접근 주체가 보호된 리소스에 접근 가능한지를 결정하는 것을 의미합니다. 이 때 권한을 부여하는 작업을 인가(Authorize)라고 합니다.
쉽게 말하면 인증은 아이디와 비밀번호를 입력 받아 로그인 하는 과정 자체를 의미하는 것이고 권한이 필요한 리소스에 접근하기 위해서는 당연히 이러한 인증 과정을 거쳐야 합니다. 스프링 시큐리는 이런 인증 매커니즘을 간단하게 만들 수 있도록 다양한 옵션들을 제공하고 있습니다. 또한 스프링 시큐리티는 웹 요청이나 메소드 호출, 도메인 인스턴스에 대한 접근 등 상당히 깊은 수준의 권한 부여를 제공하고 있습니다.
스프링 시큐리티의 구조
스프링 시큐리티는 주로 서블릿 필터와 이들로 구성된 필터체인을 사용하고 있습니다. 서블릿 필터와 관련된 설명은 이전 포스팅(링크)을 참조 부탁 드립니다. 그렇다면 실제 로그인 시에 스프링 시큐리티의 동작 플로우를 바탕으로 인증과 관련된 스프링 시큐리티의 아키텍쳐를 알아 보도록 하겠습니다.
위 그림의 동작 플로우를 간단히 설명하면 다음과 같습니다.
1. 사용자가 로그인 정보와 함께 인증 요청(Http Request)
2. AuthenticationFilter가 이 요청을 가로챕니다. 이 때 가로챈 정보를 통해 UsernamePasswordAuthenticationToken이라는 인증용 객체를 생성합니다.
3. AuthenticationManager의 구현체인 ProviderManager에게 UsernamePasswordAuthenticationToken 객체를 전달합니다.
4. 다시 AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체를 전달합니다.
5. 실제 데이터베이스에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보(아이디)를 넘겨줍니다.
6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만듭니다. 이 때 UserDetails 는 인증용 객체와 도메인용 객체를 분리하지 않고 인증용 객체에 상속해서 사용하기도 합니다.
7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교합니다.
8. 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환합니다.
9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환됩니다.
10. Authentication 객체를 SecurityContext에 저장합니다.
최종적으로 SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장합니다. 세션에 사용자정보를 저장한다는 것은 스프링 시큐리티가 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미합니다.
Spring Security Filter
위의 흐름도를 통해 개략적인 인증 과정의 흐름을 알게 되었습니다. 하지만 실제로 스프링 시큐리티는 훨씬 다양한 필터체인을 사용하여 다양한 커스터마이징을 할 수 있도록 돕습니다. 모든 필터를 다 외우고 있을 필요까지는 없겠지만 대략적인 내용을 이해하고 있으면 사용할 때 훨씬 쉽게 관련된 정보를 찾아볼 수 있을 것입니다.
- SecurityContextPersistentFilter : SecurityContextRepository에서 SecurityContext를 가져와서 SecurityContextHolder에 주입하거나 반대로 저장하는 역할을 합니다.
- LogoutFilter : logout 요청을 감시하며, 요청시 인증 주체(Principal)를 로그아웃 시킵니다.
- UsernamePasswordAuthenticationFilter : login 요청을 감시하며, 인증 과정을 진행합니다.
- DefaultLoginPageGenerationFilter : 사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지로 넘어가게 합니다.
- BasicAuthenticationFilter : HTTP 요청의 (BASIC)인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장합니다.
- RememberMeAuthenticationFilter : SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체 요청이 있을 경우, RememberMe를 인증 토큰으로 컨텍스트에 주입합니다.
- AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자로 취급합니다.
- SessionManagementFilter : 요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우 SessionAuthenticationStrategy를 호출하여 세션 고정 보호 매커니즘을 활성화 하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행합니다.
- ExceptionTranslationFilter : 필터체인 내에서 발생되는 모든 예외를 처리합니다.
- FilterSecurityInterceptor : AccessDecisionManager로 권한부여처리를 위임하고 HTTP 리소스의 보안 처리를 수행합니다.
JWT(Json Web Token)
JWT(Json Web Token)은 JSON 객체를 통해 안전하게 정보를 전송할 수 있는 웹표준(RFC7519) 입니다. JWT는 '.'을 구분자로 세 부분으로 구분되어 있는 문자열로 이루어져 있습니다. 각각 헤더는 토큰 타입과 해싱 알고리즘을 저장하고, 내용은 실제로 전달할 정보, 서명에는 위변조를 방지하기위한 값이 들어가게 됩니다.
JWT는 JSON 객체를 암호화 하여 만든 문자열 값으로 위, 변조가 어려운 정보라고 할 수 있습니다. 또, 다른 토큰들과 달리 토큰 자체에 데이터를 가지고 있다는 특징이 있습니다. JWT의 이러한 특징 때문에 사용자의 인증 요청시 필요한 정보를 전달하는 객체로 사용할 수 있습니다.
API 서버는 로그인 요청이 완료되면 클라이언트에게 회원을 구분할 수 있는 정보를 담은 JWT를 생성하여 전달합니다. 그러면 클라이언트는 이 JWT를 헤더에 담아서 요청을 하게 됩니다. 권한이 필요한 요청이 있을 때 마다 API 서버는 헤더에 담긴 JWT 값을 확인하고 권한이 있는 사용자인지 확인하고 리소스를 제공하게 됩니다.
이렇게 기존의 세션-쿠키 기반의 로그인이 아니라 JWT같은 토큰 기반의 로그인을 하게 되면 세션이 유지되지 않는 다중 서버 환경에서도 로그인을 유지할 수 있게 되고 한 번의 로그인으로 유저정보를 공유하는 여러 도메인에서 사용할 수 있다는 장점이 있습니다.
프로젝트 생성 구조
필요 dependencies :
- spring-security
- spring-web
- (필요 DB Driver)
- jwt
- lombox
## 작성 경로 ##
.
├──src
| ├── build.gradle
| ├── main
| ├── └──java
| ├── ───└── {패키지경로}
| ├── ├── resources
└─└── └── └── application.yml
초기 셋팅 하기
필요한 dependencies 는 다음 builde.gradle 파일을 참고하시기 바랍니다.
/src/build.gradle
plugins {
id 'org.springframework.boot' version '2.5.14-SNAPSHOT'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
maven { url 'https://repo.spring.io/snapshot' }
}
dependencies {
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.28'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
DB 연결 및 프로젝트 접속 포트 설정을 위해 application.yml 파일을 아래와 같이 수정했으니 참고 해주시면 됩니다.
/src/resources/application.yml
server:
port: 8082
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://:3356/jwt_test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: root
password: *********
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQL8Dialect
show-sql: true # 실행 쿼리 출력
hibernate:
use-new-id-generator-mappings: true
ddl-auto: create # 생성된 Entity 활용한 테이블 생성(재 시작시 재 생성)
properties:
hibernate:
format_sql: true
코드 작성 하기
이제 직접 코드를 작성해 볼 시간이 되었습니다. 우선 Spring boot gradle 프로젝트를 생성하고 필요한 클래스를 생성해 두겠습니다.
## 패키지 경로 및 포스팅 작성 순서
{패키지경로}
├── JwtDemoApplication
├── config
| ├── jwt
| ├── ├── JwtAuthenticationFilter.java (순서 5)
| ├── └── JwtAuthenticationPrivider.java (순서 4)
| security
| └── WebSecurityConfig.java (순서 3)
| controller
| └── UserController.java (순서 7)
| entity
| └── User.java (순서 1)
| repository
| └── UserRepository.java (순서 2)
| service
└─└── CostomUserDetailService.java (순서 6)
1. Entity, Repostory 작성
SpringSecurity는 UserDetails 객체를 통해 권한 정보를 관리하기 때문에 User 클래스에 UserDetails 를 구현하고 추가 정보를 재정의 해야 합니다. Entity와 UserDetails는 구분할 수도 같은 클래스에서 관리할 수도 있습니다. 여기에서는 같은 클래스에서 관리하는 방법을 사용하도록 하겠습니다.
{패키지경로}/entity/User.java
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100, nullable = false, unique = true)
private String userId;
@Column(length = 255, nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return userId;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
getUsername을 통해 spring security에서 사용하는 username을 가져갑니다. 저희가 사용할 username은 userId 입니다.
UserRepository 에서 userId 값 을 통해 유저를 찾는 findByUserId 메소드 함께 작성해 줍니다.
{패키지경로}/repository/UserRepository.java
import com.example.jwtdemo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUserId(String userId);
}
Spring Security를 사용하기 위해서는 Spring Security Filter Chain 을 사용한다는 것을 명시해 줘야 합니다. 이것은 WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity 어노테이션을 달아주면 해결됩니다. 자세한 것은 코드를 통해 설명하겠습니다.
{패키지경로}/config/security/WebSecurityConfig.java
import com.example.jwtdemo.config.jwt.JwtAuthenticationFilter;
import com.example.jwtdemo.config.jwt.JwtAuthenticationProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtAuthenticationProvider jwtAuthenticationProvider;
// 암호화에 필요한 PasswordEncoder 를 Bean 등록합니다.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager를 Bean 등록합니다.
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
.csrf().disable() // csrf 보안 토큰 disable처리.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtAuthenticationProvider),
UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
}
}
위 코드에서 WebSecurityConfigurerAdapter를 상속받은 WebSecurityConfig에 @EnableWebSecurity 애너테이션이 붙어 있는 것을 볼 수 있습니다. 그리고 Override 된 confiure 메소드에서 "/admin/**", "/user/**" 형식의 URL로 들어오는 요청에 대해 인증을 요구하고 있습니다.
추가된 라이브러리를 사용해서 JWT를 생성하고 검증하는 컴포넌트를 만들어 보도록 하겠습니다. JWT에는 토큰 만료 시간이나 회원 권한 정보등을 저장할 수 있습니다.
{패키지경로}/config/jwt/JwtAuthenticationProvider.java
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
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 javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@RequiredArgsConstructor
@Component
public class JwtAuthenticationProvider {
private String secretKey = "dev-truly";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
토큰을 생성하고 검증하는 컴포넌트를 완성했지만 실제로 이 컴포넌트를 이용하는 것은 인증 작업을 진행하는 Filter 입니다. 이 필터는 검증이 끝난 JWT로부터 유저정보를 받아와서 UsernamePasswordAuthenticationFilter 로 전달해야 합니다.
{패키지경로}/config/jwt/JwtAuthenticationFilter.java
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtAuthenticationProvider jwtAuthenticationProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtAuthenticationProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인합니다.
if (token != null && jwtAuthenticationProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtAuthenticationProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
토큰에 저장된 유저 정보를 활용해야 하기 때문에 CustomUserDetatilService 라는 이름의 클래스를 만들고 UserDetailsService를 상속받아 재정의 하는 과정을 진행합니다.
{패키지경로}/config/service/CustomUserDetailService.java
import com.example.jwtdemo.repository.UserRepository;
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;
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUserId(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
이제 실제로 Controller 에서 회원 가입과 로그인을 통한 인증 과정을 진행해 보도록 하겠습니다.
{패키지경로}/controller/UserController.java
import com.example.jwtdemo.config.jwt.JwtAuthenticationProvider;
import com.example.jwtdemo.entity.User;
import com.example.jwtdemo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Map;
@RequiredArgsConstructor
@RequestMapping(path = "/api/user/")
@RestController
public class UserController {
private final PasswordEncoder passwordEncoder;
private final JwtAuthenticationProvider jwtAuthenticationProvider;
private final UserRepository userRepository;
// 회원가입
@PostMapping("/join")
public Long join(@RequestBody Map<String, String> user) {
return userRepository.save(User.builder()
.userId(user.get("user_id"))
.password(passwordEncoder.encode(user.get("user_pwd")))
.roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정
.build()).getId();
}
// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
User member = userRepository.findByUserId(user.get("user_id"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 계정 입니다."));
if (!passwordEncoder.matches(user.get("user_pwd"), member.getPassword())) {
throw new IllegalArgumentException("잘못된 비밀번호입니다.");
}
return jwtAuthenticationProvider.createToken(member.getUsername(), member.getRoles());
}
}
이제 애플리케이션을 실행하고 POSTMAN등의 도구를 이용해 테스트를 해 봅니다. /api/user/join 으로 POST 요청을 보내서 회원가입 테스트를 진행 합니다.
Header Contest-Type 을 application/json 으로 설정 해주고 Body 값을 아래와 같이 설정해 줍니다.
위와 같이 API가 정상 호출이 되었으면 연결 된 DB에 user 테이블의 값이 아래와 같이 정상 등록 되어 있는지 확인 해주시면 됩니다.
/api/user/join 페이지를 호출한 방법과 동일 하게
/api/user/login 으로 POST 요청을 보내면 토큰을 생성해 반환 하는 것을 볼 수 있습니다.
이제 돌려 받은 토큰을 header 에 "X-AUTH-TOKEN" 에 담아 제한된 리소스에 대한 요청을 하게 되면 토큰을 통해 권한을 확인하고 리소스를 반환하게 됩니다.
참고 URL : https://webfirewood.tistory.com/115
'JAVA > SpringBoot' 카테고리의 다른 글
[Spring Boot] gradle 환경 log4j2 설정 (0) | 2022.10.23 |
---|---|
[SpringBoot] Swagger (0) | 2022.05.09 |
[Spring Boot] CORS 설정하기 (0) | 2022.05.07 |
Spring Security 스프링 시큐리티 (0) | 2022.04.19 |
Spring Security 일회성 Api Key 추가 (0) | 2022.04.19 |