🌱 오늘의 주제 : JWT 토큰 소개와 Spring Security + JWT 코드 공유
🌱 Stateful 서버 vs Stateless 서버
Stateful 서버
stateful 서버는 클라이언트에게 요청을 받을 때 마다, 클라이언트의 상태를 계속해서 유지하고, 이 정보를 서비스 제공에 이용합니다.
예를 들어, 세션이 있습니다. 예를 들어, 유저가 로그인을 하면, 세션에 로그인이 되었다고 저장을 해두고, 서비스를 제공할 때에 그 데이터를 사용합니다. 세션은 서버컴퓨터의 메모리 혹은 데이터베이스 시스템에 담습니다.
Stateless 서버
Stateless 서버는 상태를 유지하지 않습니다. 상태정보를 저장하지 않으며, 서버는 클리언트측에서 들어오는 요청만으로만 작업을 처리합니다. 이렇게 상태가 없는 경우 클라이언트와 서버의 연결고리가 없기 때문에 서버의 확장성(Scalability)이 높아집니다.
예를 들어, 토큰이 있습니다. 토큰 기반 인증 시스템을 사용하는 서비스들은 페이스북, 깃헙, 구글 등이 있습니다.
🌱 서버(세션) 기반 인증 vs 토큰 기반 시스템의 작동 원리
토큰 기반 시스템의 작동 원리
- 토큰 기반 시스템은 stateless 입니다.
- 유저의 인증 정보를 서버나 세션에 담아두지 않습니다.
- 웹서버에서 토큰을 서버에 전달 할 때에는 HTTP 요청의 헤더에 토큰값을 포함시켜서 전달합니다.
- 유저가 아이디와 비밀번호로 로그인을 합니다
- 서버측에서 해당 계정정보를 검증합니다.
- 계정정보가 정확하다면, 서버측에서 유저에게 signed 토큰을 발급해줍니다.
여기서 signed 의 의미는 해당 토큰이 서버에서 정상적으로 발급된 토큰임을 증명하는 signature 를 지니고 있다는 것입니다 - 클라이언트 측에서 전달받은 토큰을 저장해두고, 서버에 요청을 할 때 마다, 해당 토큰을 함께 서버에 전달합니다.
- 서버는 토큰을 검증하고, 요청에 응답합니다.
서버 기반 인증 (세션)
- 과거 인증시스템에서는 서버 기반 인증으로 이루어져 있습니다.
- 서버측에서 유저들의 정보를 기억하고 있어야 합니다.
- 메모리/ 디스크 / 데이터베이스 시스템에 이를 담았습니다.
- 로그인 중인 유저의 수가 늘어나면 서버의 램이 과부화가 되고, 데이터베이스의 성능에 무리를 주는 문제점이 있습니다.
- 서버를 확장하는 것이 어려워집니다. 즉, 더 많은 트래픽을 감당하기 위하여 여러개의 프로세스를 돌리거나, 여러대의 서버 컴퓨터를 추가하는 것이 어려워 지는 것입니다.
- CORS(Cross-Origin Resource Sharing)문제가 발생합니다. 즉, 쿼리를 여러 도메인에서 관리하는 것이 좀 번거롭습니다.
🌱 토큰의 장점
- 무상태(stateless)이며 확장성(scalability)
- 토큰은 클라이언트사이드에 저장하기 때문에 완전히 stateless하며, 서버를 확장하기에 매우 적합한 환경을 제공합니다.
- 보안성
- 클라이언트가 서버에 요청을 보낼 때, 더 이상 쿠키를 전달하지 않음으로 쿠키를 사용함으로 발생하는 취약점이 사라집니다.
- 확장성(Extensibility)
- 토큰을 사용하여 다른 서비스에서도 권한을 공유할 수 있습니다. (facebook, linkedin,github, google)
- 여러 플랫폼 및 도메인
- 토큰만 유효하다면 어떤 디바이스, 도메인에서도 요청이 정상적으로 처리됩니다. (CORS 문제 해결)
- 서버측에서 어플리케이션의 응답부분에 다음 헤더만 포함시켜주면 됩니다.
- Access-Control-Allow-Origin: *
🌱 JSON Web Token 이란?
기본 정보
JSON Web Token (JWT) 은 웹표준 (RFC 7519) 으로서 두 개체에서 JSON 객체를 사용하여 가볍고 자가수용적인 (self-contained) 방식으로 정보를 안전성 있게 전달해줍니다.
수많은 프로그래밍 언어에서 지원됩니다
JWT 는 C, Java, Python, C++, R, C#, PHP, JavaScript, Ruby, Go, Swift 등 대부분의 주류 프로그래밍 언어에서 지원됩니다.
자가 수용적 (self-contained) 입니다
JWT 는 필요한 모든 정보를 자체적으로 지니고 있습니다. JWT 시스템에서 발급된 토큰은, 토큰에 대한 기본정보, 전달 할 정보 (로그인시스템에서는 유저 정보를 나타내겠죠?) 그리고 토큰이 검증됐다는것을 증명해주는 signature 를 포함하고있습니다.
쉽게 전달 될 수 있습니다
JWT 는 자가수용적이므로, 두 개체 사이에서 손쉽게 전달 될 수 있습니다. 웹서버의 경우 HTTP의 헤더에 넣어서 전달 할 수도 있고, URL 의 파라미터로 전달 할 수도 있습니다.
🌱 JWT의 생김새
JWT 는 . 을 구분자로 3가지의 문자열로 되어있습니다. 구조는 다음과 같이 이루어져있습니다:
헤더 (Header)
Header 는 두가지의 정보를 지니고 있습니다.
typ: 토큰의 타입을 지정합니다. 바로 JWT 이죠.
alg: 해싱 알고리즘을 지정합니다. 해싱 알고리즘으로는 보통 HMAC SHA256 혹은 RSA 가 사용되며, 이 알고리즘은, 토큰을 검증 할 때 사용되는 signature 부분에서 사용됩니다.
정보 (payload)
Payload 부분에는 토큰에 담을 정보가 들어있습니다. 여기에 담는 정보의 한 ‘조각’ 을 클레임(claim) 이라고 부르고, 이는 name / value 의 한 쌍으로 이뤄져있습니다. 토큰에는 여러개의 클레임 들을 넣을 수 있습니다.
클레임 의 종류는 다음과 같이 크게 세 분류로 나뉘어져있습니다:
등록된 (registered) 클레임,
공개 (public) 클레임,
비공개 (private) 클레임
서명 (signature)
JSON Web Token 의 마지막 부분은 바로 서명(signature) 입니다. 이 서명은 헤더의 인코딩값과, 정보의 인코딩값을 합친후 주어진 비밀키로 해쉬를 하여 생성합니다.
서명 부분을 만드는 슈도코드(pseudocode)의 구조는 다음과 같습니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
이렇게 만든 해쉬를, base64 형태로 나타내면 됩니다 (문자열을 인코딩 하는게 아닌 hex → base64 인코딩을 해야합니다)
관련 사이트 : https://jwt.io/
🌱 Spring Security + JWT 시퀀스 다이어그램
1. 사용자가 로그인을 합니다.
2. Http request를 JWT authentication filter에 보냅니다.
3,4. JWT authentication filter에서는 토큰이 유효한지 검사를 하고 토큰으로 얻은 유저 정보를 Spring Security Filter에 보냅니다.
5. UserDetailService의 메소드인 loadUserByUsername으로 회원 정보를 가져옵니다.
6,7. 회원 정보를 DB에서 조회합니다.
8, 9, 10. 회원 정보를 Spring Security Filter Chain으로 보냅니다.
11. 비밀번호를 체크합니다.
12,13. 인증이 성공이면, JWT authentication filter에서 토큰을 발행합니다.
14,15 Response 헤더 부분에 Bearer JWT를 담아 보냅니다.
여기까지 전체적인 시퀀스 다이어그램이었습니다.
- 11번에 비밀번호 체크는 SecurityFilterChain에 있는 UsernamePasswordAuthenticationFilter를 통해 합니다.
- 11, 12번 로직을 그림으로 나타내면 아래와 같습니다.
- 참고
UserDetails 란?
Spring Security에서 사용자의 정보를 담는 인터페이스이다.
Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드들이 있다.
UserDetailsService 란?
Spring Security에서 유저의 정보를 가져오는 인터페이스이다.
Spring Security에서 유저의 정보를 불러오기 위해서 구현해야하는 인터페이스로 기본 오버라이드 메서드가 있다.
예를 들어 loadUserByUsername
스프링 시큐리티가 제공하는 필터들
Spring Security는 기본적으로 여러개의 Filter객체들이 순차적으로 수행되는 방식입니다. Spring Security는 Filter객체들의 집합체라고도 말할 수 있습니다.
🌱 build.gradle
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
🌱 AuthenticationResponse
package com.smartChart.auth;
import com.smartChart.patient.entity.Patient;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationResponse {
private String token;
}
🌱 ApplicationConfig
package com.smartChart.config;
import com.smartChart.doctor.repository.DoctorRepository;
import com.smartChart.patient.repository.PatientRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
// to get the user from Database
@Configuration //@Configuration이라고 하면 설정파일을 만들기 위한 애노테이션 or Bean을 등록하기 위한 애노테이션
@RequiredArgsConstructor //새로운 필드를 추가할 때 다시 생성자를 만들어서 관리해야하는 번거로움을 없애준다. (@Autowired를 사용하지 않고 의존성 주입)
public class ApplicationConfig {
private final PatientRepository patientRepository;
private final DoctorRepository doctorRepository;
// 등록되 유저가 아닐 경우
@Bean
public UserDetailsService userDetailsService() {
return patient -> (UserDetails) patientRepository.findByEmail(patient)
.orElseThrow(() -> new UsernameNotFoundException("등록된 유저가 아닙니다."));
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
🌱 JwtAuthenticationFilter
package com.smartChart.config;
import com.smartChart.token.TokenRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
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.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
// JwtAuthFilter
@Component //
@RequiredArgsConstructor //
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService; // database와 매칭할 꺼라서 final
private final TokenRepository tokenRepository;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
// Check JWT token
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userEmail;
if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7); // Bearer 앞 공간이 7자라서..
userEmail = jwtService.extractUsername(jwt);
// 만약 유저가 있다면 ~
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
// 토큰을 찾은 후, 만료되지 않았다고 표시하기.
var isTokenValid = tokenRepository.findByToken(jwt)
.map(t -> !t.isExpired() && !t.isRevoked())
.orElse(false);
// to check that token is validated // token이 유요한지 유효하지 않은지 검사
if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid){
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// update the security contextHolder
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request,response);
}
}
🌱 JwtService
package com.smartChart.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
// JwtService
@Service
public class JwtService {
private static final String SECRET_KEY = "XUSMhedg8DLHCk/ArcQGfGpQfYrBMpifzjUe1nq+D3tm4WVgXrJl2pxyVPccZDosjqZA8ZVvNb4dAD6evkfrFVVh5/b2PQ54GCbrrzoBuccbAMsz63ASB88C9d4PzpdGs2xsbpUvh/ODF2WmRvHxwtZde55NDHfn/q5MJ32vAQQdCJam1Zez2dTXjEo9qta1m21m4+i/dpyzZZcKqzN/T64vVDzzysqXxuRaZ4J94RnvveLe/cFpL3hG8KZMTtdNEjL0dfsyo93tDV0pZswI5LsHjig1pHJzwnuYtZZ36WpF5dd14UIA2qIIai0YJlfv3V/hl5hqrfro3aMBCx+8BMJvUz8HxRIKnstQfWgvjhY=\n";
public String extractUsername(String token) {
return extractClaims(token, Claims::getSubject); // subject = email, or userName
}
public <T> T extractClaims(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
// generate token, extraClaims, userDetails
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails
) {
return Jwts
.builder()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis())) // issue date
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
.signWith(getSignInkey(),SignatureAlgorithm.HS256)
.compact(); // generate and return token
}
// username with token is the same with userDetails
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaims(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token ) {
return Jwts
.parserBuilder()
.setSigningKey(getSignInkey()) // to create and decode a token
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSignInkey() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
}
🌱 SecurityConfiguration
package com.smartChart.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
// binding
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf()
.disable()
.authorizeHttpRequests()
.antMatchers( "/patient/**","/error")
.permitAll()
.antMatchers("/codes/**").permitAll()
.antMatchers( "/doctor/**")
.permitAll()
.antMatchers("/","/css/**","/images/**","/js/**","/image/**", "/oauth2/**","/login/oauth2/**","/auth/**", "/websocket/**", "/static/**","/api/**","/naver/**","/kakaoPay/**", "/vertifyIamport/**").permitAll()
.antMatchers("/login", "/join").permitAll() // 로그인, 회원가입 접근 가능
.antMatchers("/ws/**").permitAll()
//
// .antMatchers(GET,"/patient/**").hasAuthority(PATIENT_READ.name())
// .antMatchers(POST,"/patient/**").hasAuthority(PATIENT_CREATE.name())
// .antMatchers(PUT,"/patient/**").hasAuthority(PATIENT_UPDATE.name())
// .antMatchers(DELETE,"/patient/**").hasAuthority(PATIENT_DELETE.name())
//
//
// .antMatchers( "/doctor/**").hasRole(DOCTOR.name())
//
// .antMatchers(GET,"/doctor/**").hasAuthority(DOCTOR_READ.name())
// .antMatchers(POST,"/doctor/**").hasAuthority(DOCTOR_CREATE.name())
// .antMatchers(PUT,"/doctor/**").hasAuthority(DOCTOR_UPDATE.name())
// .antMatchers(DELETE,"/doctor/**").hasAuthority(DOCTOR_DELETE.name())
.anyRequest().authenticated() // 위의 경로 이외에는 모두 인증된 사용자만 접근 가능
.and()
.oauth2Login()
.and()
.sessionManagement()
// .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/patient/sign_out")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler(
((request, response, authentication) -> SecurityContextHolder.clearContext()))
.and();
return http.build();
}
}
🌱 PatientSerivce
package com.smartChart.patient.Service;
import com.smartChart.auth.AuthenticationResponse;
import com.smartChart.config.JwtService;
import com.smartChart.patient.dto.RequestDto.PatientJoinRequest;
import com.smartChart.patient.dto.RequestDto.PatientLoginRequest;
import com.smartChart.patient.dto.RequestDto.PatientMypageInterface;
import com.smartChart.patient.dto.RequestDto.PatientMypageListInterface;
import com.smartChart.patient.dto.ResponseDto.MailResponse;
import com.smartChart.patient.entity.Patient;
import com.smartChart.patient.entity.Role;
import com.smartChart.patient.repository.PatientRepository;
import com.smartChart.reservation.entity.Reservation;
import com.smartChart.reservation.repository.ReservationRepository;
import com.smartChart.token.Token;
import com.smartChart.token.TokenRepository;
import com.smartChart.token.TokenType;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PatientService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final PatientRepository patientRepository;
private final TokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final ReservationRepository reservationRepository;
private final JavaMailSender mailSender;
/**
* 회원가입
*
* @param request
* @return
*/
public AuthenticationResponse register(PatientJoinRequest request) {
var patient = Patient.builder()
// Patient
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.gender(request.getGender())
.age(request.getAge())
.phoneNumber(request.getPhoneNumber())
.role(Role.PATIENT)
.oauth(request.getOauth())
.build();
patientRepository.save(patient);
var savedPatient = patientRepository.save(patient);
var jwtToken = jwtService.generateToken(patient); // jwtService에서 token 가져오기
savePatientToken(savedPatient, jwtToken);
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
/**
* 로그인
*
* @param request
* @return
*/
public AuthenticationResponse authenticate(PatientLoginRequest request) {
// 유저가 올바르다면..
authenticationManager.authenticate( // to take object to users' token
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
var patient = patientRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new RuntimeException("code: 500" + "message : 로그인 정보가 올바르지 않습니다.")); // 유저가 올바르지 않다면...
var jwtToken = jwtService.generateToken(patient);
revokeAllPatientTokens(patient); // 모든 사용자 토큰 철회
savePatientToken(patient, jwtToken); // 사용자 토큰 저장
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
/**
* 모든 환자의 토큰을 철회하기
*
* @param patient
*/
private void revokeAllPatientTokens(Patient patient) {
var validPatientTokens = tokenRepository.findAllValidTokensByPatient(patient.getId());
if (validPatientTokens.isEmpty())
return;
validPatientTokens.forEach(t -> {
t.setExpired(true);
t.setRevoked(true);
});
tokenRepository.saveAll(validPatientTokens);
}
/**
* 환자 토큰 저장
*
* @param patient
* @param jwtToken
*/
private void savePatientToken(Patient patient, String jwtToken) {
var token = Token.builder() // 객체를 생성할 수 있는 빌더를 builder() 함수
.patient(patient)
.token(jwtToken)
.tokenType(TokenType.BEARER)
.revoked(false)
.expired(false)
.build();
tokenRepository.save(token);
}
}
* 참고 사이트 :
https://programmer93.tistory.com/68
https://cjw-awdsd.tistory.com/45
https://cjw-awdsd.tistory.com/45
'Spring' 카테고리의 다른 글
Let's Encrypt와 Nginx로 HTTPS 만들기 (0) | 2023.12.17 |
---|---|
포트원 결제 - 카카오페이 (Spring, Java) With 시퀀스 다이어그램 (0) | 2023.09.13 |
Kakao 로그인(OAuth 2.0) in Spring Boot With 시퀀스 다이어그램 (0) | 2023.09.12 |
Spring - OAuth2.0 + Social Login (0) | 2023.08.22 |
Spring - web-socket (0) | 2023.08.06 |