일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 프로세스 할당
- 프로세스 동기화
- 스프링부트
- 웹 프로그래밍
- 리눅스
- 운영체제
- 스프링
- 문제풀이
- Shared Page
- 알고리즘
- 자바 알고리즘
- spring
- 메모리 관리
- CS
- linux
- springboot
- annotation
- Segmentation with Paging
- jpa
- Effective Access Time
- 웹개발
- Allocation of Physical Memory
- Inverted Page Table
- Page Table의 구현
- 프로세스 불연속 할당
- 2단계 Page Table
- 자바 문제풀이
- 메모리의 불연속적 할당
- 코드스테이츠 백엔드 과정 39기
- 다단계 페이지 테이블
- Today
- Total
GrowMe
JWT를 SpringBoot 프로젝트에 적용하기(2) - Refresh Token 적용 본문
JWT(Json Web Token) 적용 - Refresh Token
# 들어가기 전에
# Refresh Token
# Refresh 토큰을 적용한 JWT 로그인 인증 절차
# JWT - Refresh Token 적용하기
# 동작 테스트
*들어가기 전에
https://grow-myself.tistory.com/37
[Spring] 스프링 시큐리티의 개념과 구조
Spring Security # Spring Security 개념 # 필터 # Spring Security 특징 # Spring Security 구조 # 필터 별 기능 *스프링 시큐리티란? 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 ..
grow-myself.tistory.com
https://grow-myself.tistory.com/38
[Spring] 스프링부트에서의 스프링 시큐리티 기본 세팅
Spring Security 기본 세팅 # Gradle 추가 및 SecurityConfig 파일 생성 # 예외처리 # 로그인 화면을 커스텀 페이지로 # id, pw 인증 처리 # 비밀번호 암호화 방식 커스텀 *Gradle 추가 / SecurityConfig - bu..
grow-myself.tistory.com
https://grow-myself.tistory.com/45
JWT를 SpringBoot 프로젝트에 적용하기
JWT(JSON Web Token) # JWT란 # JWT의 구조 # 인증 절차 # JWT 적용하기(인증 처리) # JWT 적용하기(인가 처리) *JWT란? JSON을 기반으로, 인증에 필요한 정보들을 암호화 시킨 토큰이다. 토큰이란? 마치 일..
grow-myself.tistory.com
본 포스팅 내용을 이해하기 위해서는 스프링 시큐리티와 JWT에 대한 이해 및 Access 토큰의 적용방법을 숙지하고 있어야 수월하여 위 포스팅 내용들을 먼저 읽어보고 오시기를 권장드립니다.
*Refresh Token
- 앞선 JWT 적용 포스팅에서 Access 토큰을 통한 로그인 처리를 알아보았다.
- 하지만, 이 Access 토큰이 만약 탈취당한다면? 설정된 유효기간 동안 무한적으로 로그인이 가능하여 심각한 피해가 일어날 수 있다.
- 이를 방지하기 위한 것이 바로 Refresh Token이다. 클라이언트로부터 요청이 왔을 때 Access Token의 유효기간이 만료되면 Refresh Token을 검증하여 유효하다면 Access Token을 재발급 해주는 방식이다.
- 이를 통해 Access Token의 유효기간을 30분 등의 매우 짧은 단위로 적용할 수 있어, 탈취를 당하더라도 위험도가 상당히 줄어들 수 있다.
*Refresh 토큰을 적용한 JWT 로그인 인증 절차
- Access Token 유효할 시
- 사용자 로그인
- 사용자 확인
- 응답( + Access Token, Refresh Token) 이 때 Refresh Token은 DB에 별도 저장
- 클라이언트 -> 데이터 API 요청
- Access Token 검증
- 응답(+요청 데이터)
- Access Token 만료 시
- 클라이언트 -> 데이터 요청(+ Access Token)
- Access Token 만료 확인
- 응답 ( + Access Token이 만료 되었습니다)
- 클라이언트 -> Access Token 발급 요청(Access Token, Refresh Token)
- Refresh Token 검증 및 확인 후(토큰 유효, DB에 저장해 두었던 원본 Refresh 토큰과도 비교)
- 응답 ( + 새로운 Access Token + 새로운 Refresh Token)
-> 이렇게 처리한 이유는 만약 해커가 Refresh Token을 탈취하여 정상적인 사용자가 Access 토큰 발급 받기 전에 해커가 먼저 Access 토큰을 생성하여 접근하는 경우 : Access Token이 충돌이 일어난다. 따라서 충돌된 두 토큰을 모두 폐기해야할 것이다.
이를 애초에 방지하기 위해 국제 인터넷 표준화 기구인 IETF의 공식문서에서는 아예 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여 사용자가 한 번 Refresh Token으로 Access Token을 발급받았으면 Refresh Token도 다시 발급받도록 하는 것을 권장하고 있다고 한다.
-> 다시한번 공부해보니, 이처럼 처리하려면, Access Token도 DB에 Refresh Token과 함께 같은 행으로서 저장되야하는 것 같다.
-> 본 포스팅 내용에서는 그렇게 진행하지 않았으나, 진행하던 프로젝트의 코드를 이와 같이 리펙토링해야할 것 같다.
*JWT - Refresh Token 적용하기
1. 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:4.0.0'
- 시큐리티 적용과 jwt 적용을 위한 의존성을 추가해 줍니다.
2. application.properties
# jwt 비밀키 (아래는 예시값임)
jwt.secret=secretKey
- 검증 시 사용될 비밀키는 최대한 노출되지 않도록 환경설정 파일에서 보관해주도록 합니다.
3. TokenProvider
@Component
public class TokenProvider {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private final RefreshTokenRepository refreshTokenRepository;
private MemberRepository memberRepository;
public TokenProvider(MemberRepository memberRepository, RefreshTokenRepository refreshTokenRepository) {
this.memberRepository = memberRepository;
this.refreshTokenRepository = refreshTokenRepository;
}
@Value("{jwt.secret}")
private String accessKey;
private String refreshKey = "refresh " + accessKey;
public String createAccessToken(Authentication authentication) {
AuthDetails authDetails = (AuthDetails) authentication.getPrincipal();
return JWT.create()
.withSubject("MyPet JWT Access Token")
// .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 60)))
.withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000)))
.withClaim("id", authDetails.getMember().getMemberId())
.withClaim("email", authDetails.getEmail())
.sign(Algorithm.HMAC512(accessKey));
}
private String createRefreshToken(Authentication authentication) {
AuthDetails authDetails = (AuthDetails) authentication.getPrincipal();
return JWT.create()
.withSubject("MyPet JWT Refresh Token")
.withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 60 * 24 * 14)))
.withClaim("email", authDetails.getEmail())
.sign(Algorithm.HMAC512(refreshKey));
}
@Transactional
public String renewalRefreshToken(Authentication authentication) {
String newRefreshToken = createRefreshToken(authentication);
// 기존 토큰이 있다면 바꿔주고, 없다면 토큰을 만들어준다.
refreshTokenRepository.findByEmail(authentication.getName())
.ifPresentOrElse(
r -> {r.changeToken(newRefreshToken);
logger.info("기존 리프레시 토큰 변환");},
() -> {
RefreshToken toSaveToken = RefreshToken.createToken(authentication.getName(), newRefreshToken);
logger.info("새로운 리프레시 토큰 저장 | member's email : {}, token : {}", toSaveToken.getEmail(), toSaveToken.getToken() );
refreshTokenRepository.save(toSaveToken);
}
);
return newRefreshToken;
}
@Transactional
public String updateRefreshToken(String refreshToken) throws RuntimeException {
// refresh Token을 DB에 저장된 토큰과 비교
Authentication authentication = getAuthentication(refreshToken);
RefreshToken findRefreshToken = refreshTokenRepository.findByEmail(authentication.getName())
.orElseThrow(() -> new UsernameNotFoundException("email : " + authentication.getName() + " was not found"));
// 토큰이 일치한다면
if(findRefreshToken.getToken().equals(refreshToken)) {
// 새로운 토큰 생성
String newRefreshToken = createRefreshToken(authentication);
findRefreshToken.changeToken(newRefreshToken);
return newRefreshToken;
} else {
logger.info("refresh Token이 일치하지 않습니다.");
return null;
}
}
public Authentication getAuthentication(String token) {
String email = JWT.decode(token).getClaim("email").asString();
if(email != null) {
Member memberEntity = memberRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException(email + "데이터베이스에서 찾을 수 없습니다.")
);
AuthDetails authDetails = new AuthDetails(memberEntity);
return new UsernamePasswordAuthenticationToken(authDetails, null, authDetails.getAuthorities());
} else {
return null;
}
}
public JwtCode validateToken(String token, String tokenType) {
String key = tokenType.equals("access") ? accessKey : refreshKey;
try {
JWT.require(Algorithm.HMAC512(key)).build().verify(token);
return JwtCode.ACCESS;
} catch (TokenExpiredException e) {
logger.info("만료된 토큰입니다.");
return JwtCode.EXPIRED;
} catch (JWTVerificationException e) {
logger.info("토큰 검증에 실패하였습니다.");
return JwtCode.DENIED;
}
}
public static enum JwtCode {
DENIED,
ACCESS,
EXPIRED;
}
}
- @Value("{jwt.secret}") : 우선 환경 설정 파일에서 저장했던 비밀키값을 불러와 저장한다.
특정 문자열을 추가하여 리프레시 토큰과 액세스 토큰의 키값을 구분하였다. - createAccessToken() : Authentication 객체가 인자로 오면, 그 객체에 저장된 유저 정보를 기반으로 액세스 토큰을 생성하여 토큰 값을 반환한다.
createRefreshToken() 메서드도 마찬가지다. - renewalRefreshToken() : 인자로 들어온 Authentication 객체를 통해 새 리프레시 토큰을 생성하고, DB에 기존 리프레시 토큰이 있다면 바꿔주고 없다면 추가해준다. 로그인 시 이 메서드가 실행된다.
- updateRefreshToken() : renuwalRefreshToken()과 유사해보이지만 다른 상황에 사용하는 메서드이다. Access 토큰이 만료되었을 때 사용하는 메서드이다. 인자로 들어온 Refresh 토큰값을 DB에 저장된 값과 비교하여, 일치할 경우에만 새 리프레시 토큰을 생성하여 기존 DB 값을 수정한 뒤 반환한다.
- getAuthentication() : 인자로 들어온 token 값에 들어있는 유저정보를 기반으로 인증 객체인 Authentication 객체를 반환한다.
- validateToken() : 인자로 들어온 토큰을 검증한다. 인자로 들어온 tokenType을 통해 액세스 / 리프레시를 구분한다. (같은 비밀키로 암호화하여 토큰을 생성하기에 이렇게 구분하여 처리하지 않으면 리프레시 토큰으로 접근해도 액세스 토큰 검증에 통과해버린다)
- JwtCode : 토큰 검증의 성공/실패/유효기간 만료를 구분하기 위해 enum 타입으로 생성해두었다.
4. JwtAuthenticationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final TokenProvider tokenProvider;
private final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
logger.info("로그인 시도");
try {
// 요청 데이터로부터 Member 객체를 받아온다.
ObjectMapper om = new ObjectMapper();
Member member = om.readValue(request.getInputStream(), Member.class);
// Member 객체에 저장되어 있는 email, pasword 값(로그인 요청시 넘어오는 값)을 통해 인증 토큰 객체를 생성한다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getEmail(), member.getPassword());
// 인증 토큰 객체를 통해 유저 인증을 시도한다. (UserDetailsService 또는 상속받은 구현체의 loadUserByUsername()가 실행됨)
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 인증이 성공하면 인증 객체를 반환하고, 실패하면 AuthenticationException을 발생시킨다.
return authentication;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, Authentication authResult) throws IOException, ServletException {
logger.info(("로그인 성공 유저 처리"));
// 액세스 토큰과 리프레시 토큰을 생성하여 응답 헤더에 담아준다.
String accessToken = tokenProvider.createAccessToken(authResult);
String refreshToken = tokenProvider.renewalRefreshToken(authResult);
response.addHeader("Authorization", "Bearer " + accessToken);
response.addHeader("refresh", "Bearer " + refreshToken);
}
}
- UsernamePasswordAuthenticationFilter 인터페이스를 상속받아야 로그인 시도 처리, 로그인 성공 후처리를 위한 필터로서 동작을 할 수 있다.
- attemptAuthentication() : 로그인 시도 시 실행되는 메서드로 인증 성공 시 인증 객체를 반환하고, 인증 실패 시 AuthenticationException을 발생시킨다.
- 기타 자세한 사항은 모두 주석처리하였으니 참고바랍니다.
5. JwtAuthorizationFilter
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private MemberRepository memberRepository;
private TokenProvider tokenProvider;
private final Logger logger = LoggerFactory.getLogger(JwtAuthorizationFilter.class);
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository, TokenProvider tokenProvider) {
super(authenticationManager);
this.memberRepository = memberRepository;
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
logger.info("인증이나 권한 필요한 주소가 요청 되었습니다.");
// 헤더에서 액세스 토큰을 받아온다.
String jwtToken = resolveToken(request ,"Authorization");
// 토큰이 존재한다면
if(jwtToken != null) {
// 토큰을 검증하고, 그 검증 후 값을 저장해둔다.
TokenProvider.JwtCode accValidResult = tokenProvider.validateToken(jwtToken, "access");
// 액세스 토큰이 유효하다면
if(accValidResult == TokenProvider.JwtCode.ACCESS) {
// 인증 객체를 얻어 SecurityContextHolder에 저장 후, 필터를 넘긴다.
Authentication authentication = tokenProvider.getAuthentication(jwtToken);
if(authentication != null) {
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
// 액세스 토큰이 만료되었다면
} else if (accValidResult == TokenProvider.JwtCode.EXPIRED) {
// 리프레시 토큰을 헤더에서 불러온다.
String refreshToken = resolveToken(request, "refresh");
// refresh Token이 요청헤더에 있다면
if(refreshToken != null) {
// refresh Token을 검증
TokenProvider.JwtCode refValidResult = tokenProvider.validateToken(refreshToken, "refresh");
// 검증이 통과하면
if(refValidResult != TokenProvider.JwtCode.DENIED) {
// refresh Token 재발급
String newRefreshToken = tokenProvider.updateRefreshToken(refreshToken);
if(newRefreshToken != null) {
response.addHeader("refresh", "Bearer " + newRefreshToken);
// Access Token 재발급
Authentication authentication = tokenProvider.getAuthentication(refreshToken);
response.addHeader("Authorization", "Bearer " + tokenProvider.createAccessToken(authentication));
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.info("리프레시 토큰 & 액세스 토큰 최신화 완료");
}
}
} else
// refresh Token이 요청헤더에 없다면
// 클라이언트에 Access Token이 만료되었음을 알리고, refresh Token을 요청헤더에 달라고 요청(예외 처리)
// 해당 처리는 아래에서 구현할 ErrorhandlerFilter 클래스에서 처리한다.
throw new TokenExpiredException("토큰 만료", Instant.now());
}
} else {
logger.info("유효한 토큰을 찾지 못하였습니다, uri : {}", request.getRequestURI());
}
filterChain.doFilter(request, response);
}
// 토큰을 헤더에서 불러오는 메서드
private String resolveToken(HttpServletRequest request, String header) {
String jwtHeader = request.getHeader(header);
if(jwtHeader != null && jwtHeader.startsWith("Bearer")) {
return jwtHeader.replace("Bearer ", "");
}
return null;
}
}
- BasicAuthenticationFilter을 상속받아 doFilterInternal() 메서드를 오버라이딩하면, 해당 메서드 내에서 인가 처리가 필요한 주소가 요청되었을 때의 처리가 가능하다.
- 1. 액세스 토큰이 유효하다면 그대로 인가를 통과시킨다.
2. 만료되었고 리프레시 토큰이 헤더에 존재하지 않는다면 TokenExpiredException을 발생시킨다.
3. 만료되었고, 리프레시 토큰이 헤더에 존재한다면 // 토큰을 검증 후 리프레시 토큰과 액세스 토큰을 함께 재발급한다.
6. ErrorhandlerFilter
@Component
public class ErrorhandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (TokenExpiredException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("refresh token need");
}
}
}
- OncePerRequestFilter을 상속받아 필터 전체 진행 중 단 한번만 작동되는 필터를 만든다. 이 필터를 인가처리할 JwtAuthorizationFilter 전에 위치시키면 인가처리 실패시 이 필터가 동작되게 되어 인가에 대한 에러 처리를 할 수 있다.
- JwtAuthorizationFilter에서 발생시켰던 TokenExpiredException를 try / catch문으로 잡아서 해당 에러발생 시, 리프레시 토큰이 필요하다고 클라이언트에게 응답 메시지를 커스텀하여 보내주도록 처리한다.
7. JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
// 필요한 권한이 없이 접근하려할 때, 403 에러 송출
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
8. SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final MemberRepository memberRepository;
private final TokenProvider tokenProvider;
private final ErrorhandlerFilter errorhandlerFilter;
private final JwtAddLogoutHandler jwtAddLogoutHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf().disable();
httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 디폴트인 세션, 쿠키 생성 허용 -> 불허
.and()
.httpBasic().disable()
// JWT 필터 적용
.apply(new JwtLogin())
.and()
// 인가에 대한 에러처리를 위해 errorhandlerFilter를 추가
.addFilterBefore(errorhandlerFilter, JwtAuthorizationFilter.class)
.exceptionHandling()
// 권한이 필요한 요청 실패에 대한 처리를 커스텀
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/api/v1/admin/**").access("hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/user/**").access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
.anyRequest().permitAll()
.and()
.logout()
.addLogoutHandler(jwtAddLogoutHandler)
.logoutSuccessUrl("/api/v1/user/test");
return httpSecurity.build();
}
// JWT 필터를 적용시키기 위한 매개 클래스
public class JwtLogin extends AbstractHttpConfigurer<JwtLogin, HttpSecurity> {
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class);
httpSecurity
.addFilter(corsFilter)
.addFilter(new JwtAuthenticationFilter(authenticationManager, tokenProvider))
.addFilter(new JwtAuthorizationFilter(authenticationManager, memberRepository, tokenProvider));
}
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
- 위 사항들을 모두 적용시킨 Security 설정 파일이다. 설정 파일에 대한 자세한 내용은 최상단의 타 포스팅 내용을 참고 바랍니다.
9. AuthDetails
@Data
public class AuthDetails implements UserDetails {
private final Member member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> String.valueOf(member.getMemberRole()));
return authorities;
}
public String getEmail() {return member.getEmail();}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
10. AuthDetailsService
@Service
@RequiredArgsConstructor
public class AuthDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member memberEntity = memberRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException(email + "데이터베이스에서 찾을 수 없습니다.")
);
return new AuthDetails(memberEntity);
}
}
11. RefreshToken (엔티티)
@Entity
@NoArgsConstructor
@Getter
@AllArgsConstructor
@Builder
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long refreshTokenId;
private String email;
private String token;
public static RefreshToken createToken(String email, String token) {
return RefreshToken.builder().email(email).token(token).build();
}
public void changeToken(String token) {
this.token = token;
}
}
12. RefreshTokenRepository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
Optional<RefreshToken> findByEmail(String email);
void deleteByEmail(String email);
}
*동작 테스트
1. 회원가입
2. 로그인
- 액세스 토큰, 리프레시 토큰 모두 잘 들어오는 것 확인
- DB에 리프레시 토큰도 잘 저장되어있다.
3. 인증 필요 url 접근
- 잘 통과된다.
4. 유효성 만료시
- 커스텀한대로 클라이언트에 refresh 토큰이 필요하다는 메시지를 전달하고 있다.
5. Refresh Token 재발급
- 접근 불가능하던 url에 접근이 가능해졌으며 토큰이 최신화되어 DB에 저장됨을 확인.
'Security' 카테고리의 다른 글
OAuth2를 스프링부트 프로젝트에 적용하기(with JWT 토큰) (0) | 2022.10.02 |
---|---|
JWT를 SpringBoot 프로젝트에 적용하기(1) - Access 토큰 적용 (0) | 2022.08.24 |
[Spring] 스프링부트에서의 스프링 시큐리티 기본 세팅 (0) | 2022.07.05 |
[Spring] 스프링 시큐리티의 개념과 구조 (0) | 2022.07.05 |