스프링 시큐리티에서 JWT 토큰 인증 방식을 사용하기 위해서는 다음과 같은 과정을 거칩니다:
- 사용자 로그인: 클라이언트에서 사용자의 로그인 정보(예: 아이디와 비밀번호)를 서버에 전송합니다.
- 인증: 서버는 전달받은 로그인 정보를 확인하고, 사용자가 유효한지 확인합니다. 이 단계에서는 사용자의 아이디와 비밀번호를 검증하는 등의 인증 과정이 이루어집니다.
- 토큰 생성: 인증이 성공적으로 이루어지면, 서버는 사용자를 대표하는 JWT 토큰을 생성합니다. 이 토큰에는 사용자의 식별 정보와 필요한 추가 정보가 포함됩니다.
- 토큰 전달: 서버는 생성된 JWT 토큰을 클라이언트에게 전달합니다. 일반적으로는 HTTP 응답의 헤더나 JSON 응답의 일부로 토큰이 포함됩니다.
- 토큰 저장: 클라이언트는 받은 토큰을 안전한 곳에 저장합니다. 일반적으로는 웹 브라우저의 로컬 스토리지나 세션 스토리지에 저장하거나, 모바일 앱에서는 안전한 저장소에 저장합니다.
- 토큰 인증: 클라이언트가 서버에 요청을 보낼 때마다, 토큰을 함께 전달합니다. 서버는 토큰의 유효성을 확인하고, 클라이언트의 요청을 처리합니다. 이 과정에서 서버는 토큰의 서명을 확인하여 변조
여기서는 간단하게 로그인 구현하고 token을 발급하는 것에 초점을 맞춰보겠습니다
완성된 패키지 구조 입니다.
초기 설정
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.13'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
maven {
url 'https://repo.clojars.org'
name 'Clojars'
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-security'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'com.h2database:h2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}
tasks.named('test') {
useJUnitPlatform()
}
스프링 버전이 3.xx일 때 config 설정할 때 deprecated된게 많아 편의상 2.7.13으로 설정 했습니다
application.yml
jwt:
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
secret key는 terminal에서
이런식으로 간단히 만들 수 있다.
로그인
MemberController
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("login")
public ResponseEntity<String> login(@RequestBody LoginDto dto){
return ResponseEntity.ok().body(memberService.login(dto.getUsername()));
}
}
LoginDto에서 편의상 pw는 제외하고 username을 가져와 MemberService의 login 메서드에 넘겨주는 역할을 합니다.
반환값으로 String을 받습니다.
MemberService
@Service
public class MemberService {
@Value("${jwt.secret}")
private String secretKey;
private Long expiredMs = 1000 * 60 * 60l;
public String login(String username){
return JwtUtil.createJwt(username, secretKey, expiredMs);
}
}
@Value를 통해 application.yml에 있는 jwt.secret을 가져올 수 있다.
expiredMs는 token 만료시간을 설정하는 건데 1시간으로 설정 되어있다.
login메서드에는 username,secretKey, expiredMs를 JwtUtil 의 createJwt메서드에 보내는 역할을 합니다.
LoginDto
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class LoginDto {
private String username;
}
JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final MemberService memberService;
@Value("${jwt.secret}")
private final String secretKey;
// 필터 실제 로직
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
logger.info("authorization : " + authorization);
//token 안
if (authorization == null || !authorization.startsWith("Bearer ")){
logger.error("authorization을 잘못 보냈습니다.");
filterChain.doFilter(request, response);
return;
}
//token 꺼내기
String token = authorization.split(" ")[1];
logger.info("token : " + token);
//token Expired되었는지
if(JwtUtil.isExpired(token, secretKey)){
logger.error("Token이 만료 되었습니다.");
filterChain.doFilter(request, response);
return;
}
//username token에서 꺼내기
String userName = JwtUtil.getUserName(token, secretKey);
logger.info("username: " + userName);
//인증된 사용자를 나타내는 토큰 객체를 생성하고, 권한 정보를 설정
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
JwtUtil
public class JwtUtil {
public static String getUserName(String token, String secretKey){
String res = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().get("userName", String.class);
System.out.println("res : " + res);
return res;
}
public static boolean isExpired(String token, String secretKey){
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token)
.getBody().getExpiration().before(new Date());
}
public static String createJwt(String username, String secretKey, Long expiredMs){
Claims claims = Jwts.claims();
claims.put("userName", username);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
}
createJwt() : jwt생성로직
setClaims(): claim부분에 username을 담음
setIssuedAt(): 토큰 생성시간
setExpiration(): 토큰 만료시간
signWith(): 토큰의 서명 알고리즘과, SecretKey를 이용하여 서명합니다.
compact(): 설정된 정보를 바탕으로 jwt를 생성합니다. 생성된 토큰을 문자열 형태로 반환합니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig {
private final MemberService memberService;
@Value("${jwt.secret}")
private String secretKey;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeRequests()
.antMatchers("/api/v1/members/login").permitAll()
.antMatchers(HttpMethod.POST, "/api/v1/**").authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class)
.build();
}
}
- .httpBasic().disable(): HTTP 기본 인증을 비활성화합니다. 기본 인증은 사용자명과 비밀번호를 평문으로 전송하기 때문에 보안에 취약합니다.
- .csrf().disable(): CSRF(Cross-Site Request Forgery) 공격 방지 기능을 비활성화합니다. 이 설정은 RESTful API 서비스에서 CSRF 공격을 대응하기 위해 사용됩니다.
- .cors().and(): Cross-Origin Resource Sharing(CORS) 설정을 활성화합니다. CORS는 도메인이나 포트가 다른 리소스에 대한 접근을 제어하는 기능입니다.
- .authorizeRequests(): 요청에 대한 인가 규칙을 설정합니다.
- .antMatchers("/api/v1/members/login").permitAll(): "/api/v1/members/login" 경로에 대한 요청은 모든 사용자에게 허용합니다. 즉, 로그인 API는 인증 없이 접근 가능합니다.
- .antMatchers(HttpMethod.POST, "/api/v1/**").authenticated(): "/api/v1/"로 시작하는 POST 메서드의 요청은 인증된 사용자만 접근할 수 있습니다. 즉, 인증된 사용자만이 POST 요청을 보낼 수 있습니다.
- .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): 세션 관리를 설정하고, 세션 생성 정책을 STATELESS로 지정합니다. STATELESS 정책은 서버가 세션을 유지하지 않고, 각 요청마다 사용자를 인증합니다. JWT 토큰 기반의 인증을 사용할 때 주로 이 정책을 사용합니다.
- .addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class): JwtFilter를 UsernamePasswordAuthenticationFilter 이전에 추가합니다. 앞서 설명한 것처럼 JwtFilter는 토큰의 검증과 사용자 인증을 처리하는 역할을 수행합니다.
- .build(): 설정을 적용하여 HttpSecurity 객체를 빌드하고 반환합니다.
ReviewController
@RestController
@RequestMapping("/api/v1/reviews")
public class ReviewController {
@PostMapping
public ResponseEntity<String> writeReview(Authentication authentication){
return ResponseEntity.ok().body(authentication.getName() + "님의 리뷰 등록이 완료 되었습니다.");
}
}
token을 통해 인증이되면 리뷰를 등록할 수 있도록 간단히 작성 되어있습니다.
받은 토큰으로 Authorization을 "Bearer " + token 형태로 입력하고 전송하면
결과를 위와 같이 받을 수 있습니다.
https://github.com/Changha-dev/jwt-security-v1
전체 소스코드는 깃허브에 올려놨습니다!
참고 강의
https://www.youtube.com/watch?v=YEB0Ln6Lcyk
token을 어떤식으로 발급하고 사용하는지 알았으니
이제 로그인과 회원가입 로직을 제대로 추가해서 더 완성도있게 만들어봐야겠다!
'개발' 카테고리의 다른 글
[Spring Boot] AccessToken, RefreshToken을 이용한 로그인 구현 (0) | 2023.07.22 |
---|---|
[Spring Boot] jwt로 로그인, 회원가입 API 구현 (0) | 2023.07.07 |
session, cookie, jwt 관계 및 정리 (1) | 2023.07.01 |
[Spring Boot] 회원 정보 조회 및 삭제 (0) | 2023.02.12 |
[Spring Boot] 스프링 부트 회원 목록 출력하기 (0) | 2023.02.11 |