반응형
지난 시간에 쿠키를 활용한 웹로그인을 구현했다면 이번엔 모바일에서 사용할 JWT 를 구현해보겠습니다.
우선은 쿠키에 CSRF 토큰이 없을때 CSRF 체크하지 않도록 변경 (브라우저에서만 의미를 가지므로)
package com.example.user.csrf;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Supplier;
public class CustomCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final XorCsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
this.xor.handle(request, response, csrfToken);
csrfToken.get();
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
boolean hasTokenInCookie =
Arrays.stream(Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
.anyMatch(cookie -> cookie.getName().equals("XSRF-TOKEN"));
boolean hasTokenInCookie2 =
Arrays.stream(Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
.anyMatch(cookie -> cookie.getName().equals("X-TOKEN"));
if(!hasTokenInCookie || !hasTokenInCookie2){
return csrfToken.getToken();
}else{
return xor.resolveCsrfTokenValue(request, csrfToken);
}
}
}
쿠키뿐 아니라 헤더에만 있는경우 처리할수있도록 수정
package com.example.user.jwt;
import com.example.common.response.ApiResponseCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.ErrorResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.HashMap;
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUserDetailsService jwtUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 헤더에 넣는경우
try {
String token = parseJwt(request);
if (token != null) {
var userDetails = jwtUserDetailsService.loadUserByUsername(token);
if (userDetails != null) {
var usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}catch (ExpiredJwtException e) {
request.setAttribute("JwtError","expired");
} catch (Exception e) {
log.error("JWT authentication failed: {}", e.getMessage());
}
// 쿠키에 넣는경우
if(SecurityContextHolder.getContext().getAuthentication() == null){
try {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("X-TOKEN".equals(cookie.getName())) {
var token = cookie.getValue();
var userDetails = jwtUserDetailsService.loadUserByUsername(token);
if (userDetails != null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}
}
}catch(ExpiredJwtException e){
request.setAttribute("JwtError","expired");
} catch (Exception e) {
log.error("JWT authentication failed: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7, headerAuth.length());
}
return null;
}
}
Refresh 관련 추가
package com.example.user.jwt;
import com.example.common.exception.BusinessException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.List;
@Slf4j
@Service
public class JwtService {
private final Key accessKey;
public JwtService(
@Value("${jwt.access_secret}") String accessKey
) {
this.accessKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessKey));
}
//ACCESS KEY 생성
public String createAccessToken(JwtUserDto member, long accessTokenExpTime) {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(accessTokenExpTime);
return Jwts.builder()
.claim("idx", member.getIdx())
.claim("name", member.getName())
.claim("role", member.getRoles())
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(accessKey, SignatureAlgorithm.HS256)
.compact();
}
//REFRESH KEY 생성
public String createRefreshToken(JwtUserDto member, long refreshTokenExpTime) {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime tokenValidity = now.plusSeconds(refreshTokenExpTime);
return Jwts.builder()
.claim("idx", member.getIdx())
.setIssuedAt(Date.from(now.toInstant()))
.setExpiration(Date.from(tokenValidity.toInstant()))
.signWith(accessKey, SignatureAlgorithm.HS256)
.compact();
}
//파싱
public Long getUserId(String token) {
return parseAccessClaims(token).get("idx", Long.class);
}
public String getUserName(String token) {
return parseAccessClaims(token).get("name", String.class);
}
public List<String> getUserRole(String token) {
return parseAccessClaims(token).get("role", List.class);
}
public Date getIssuedAt(String token){
return parseAccessClaims(token).getIssuedAt();
}
public Claims parseAccessClaims(String accessToken) {
return Jwts.parser().setSigningKey(accessKey).build().parseClaimsJws(accessToken).getBody();
}
}
package com.example.user.jwt;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class JwtResultDto {
String accessToken;
String refreshToken;
}
package com.example.api.controller;
import com.example.api.dto.request.UserLoginRequestDto;
import com.example.api.dto.request.UserRefreshJwtRequestDto;
import com.example.api.dto.request.UserRegisterRequestDto;
import com.example.api.dto.response.UserInfoResponseDto;
import com.example.api.service.UserService;
import com.example.common.response.ApiResponse;
import com.example.common.response.ApiResponseGenerator;
import com.example.user.jwt.JwtResultDto;
import com.example.user.jwt.JwtUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
@Tag(name="USER")
@RequiredArgsConstructor
@RestController()
@RequestMapping("user")
@SecurityRequirement(name = "Bearer Authentication")
@SecurityRequirement(name = "XSRF-TOKEN")
public class UserController {
private final UserService userService;
@Operation(summary = "회원가입")
@PostMapping("/register")
public ApiResponse<Void> register(@RequestBody @Valid UserRegisterRequestDto userRegisterDto)
{
userService.register(userRegisterDto);
return ApiResponseGenerator.success();
}
@Operation(summary = "로그인")
@PostMapping("/login")
public ApiResponse<JwtResultDto> login(@Valid @RequestBody UserLoginRequestDto userLoginRequestDto, HttpServletRequest request, HttpServletResponse response){
return userService.login(userLoginRequestDto, request, response);
}
@Operation(summary = "회원정보")
@PostMapping("/info")
@Secured({"ROLE_USER"})
public ApiResponse<UserInfoResponseDto> info(@AuthenticationPrincipal JwtUserDetails jwtUserDetails){
return userService.info(jwtUserDetails);
}
@GetMapping("/csrf")
public CsrfToken csrf(CsrfToken token) {
// 이 객체는 Spring Security가 자동으로 주입해줌
return token;
}
@PostMapping("/refresh/jwt")
public ApiResponse<JwtResultDto> refreshJwt(@Valid @RequestBody UserRefreshJwtRequestDto userRefreshJwtRequestDto, HttpServletResponse response) {
return userService.refreshJwt(userRefreshJwtRequestDto, response);
}
}
package com.example.api.service;
import com.example.api.dto.request.UserLoginRequestDto;
import com.example.api.dto.request.UserRefreshJwtRequestDto;
import com.example.api.dto.request.UserRegisterRequestDto;
import com.example.api.dto.response.UserInfoResponseDto;
import com.example.common.exception.BusinessException;
import com.example.common.exception.BusinessParmException;
import com.example.common.response.ApiResponse;
import com.example.common.response.ApiResponseGenerator;
import com.example.domain.code.UserRoleCode;
import com.example.domain.entity.UserEntity;
import com.example.domain.entity.UserRoleEntity;
import com.example.domain.repository.UserRepository;
import com.example.domain.repository.UserRoleRepository;
import com.example.user.jwt.JwtResultDto;
import com.example.user.jwt.JwtService;
import com.example.user.jwt.JwtUserDetails;
import com.example.user.jwt.JwtUserDto;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Locale;
@RequiredArgsConstructor
@Service
@Transactional
public class UserService {
private final UserRepository userRepository;
private final UserRoleRepository userRoleRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
@Value("${jwt.access_secret_expiration_time}") long accessTokenExpTime;
@Value("${jwt.refresh_secret_expiration_time}") long refreshTokenExpTime;
public void register(UserRegisterRequestDto userRegisterDto) {
var userEntity = UserEntity.builder()
.name(userRegisterDto.getUserName())
.email(userRegisterDto.getEmail())
.userId(userRegisterDto.getUserId())
.password(passwordEncoder.encode(userRegisterDto.getPassword()))
.build();
userRepository.save(userEntity);
//유저권한부여
userRoleRepository.save(UserRoleEntity.builder().user(userEntity).userType(UserRoleCode.USER).build());
}
public ApiResponse<JwtResultDto> login(UserLoginRequestDto userLoginRequestDto, HttpServletRequest request, HttpServletResponse response) {
var userEntity = userRepository.findByUserId(userLoginRequestDto.getUserId()).orElseThrow(()->{
var errMap = new HashMap<String, String>();
errMap.put("userId", "아이디를 확인해주세요");
return new BusinessParmException("error", errMap);
});
if(passwordEncoder.matches(userLoginRequestDto.getPassword(), userEntity.getPassword())){
var roles = userEntity.getUserRoleList().stream().map(p->p.getUserType().getCode()).toList();
var info = JwtUserDto.builder().idx(userEntity.getIdx()).name(userEntity.getName()).roles(roles).build();
var accesstokenExpTime = accessTokenExpTime;
if(request.getHeader("x-xsrf-token") != null){
accesstokenExpTime = refreshTokenExpTime;
}
String accessToken = jwtService.createAccessToken(info, accesstokenExpTime);
String refreshToken = jwtService.createRefreshToken(info, refreshTokenExpTime);
var cookie = ResponseCookie.from("X-TOKEN", accessToken)
.path("/")
.sameSite("None")
.httpOnly(true)
.secure(true)
// .maxAge(60 * 60 * 24)
.build();;
response.addHeader("Set-Cookie", cookie.toString());
var result = JwtResultDto.builder().accessToken(accessToken).refreshToken(refreshToken).build();
return ApiResponseGenerator.success(result);
}else{
var errMap = new HashMap<String, String>();
errMap.put("password", "비밀번호를 확인해주세요");
throw new BusinessParmException("error", errMap);
}
}
public ApiResponse<UserInfoResponseDto> info(JwtUserDetails jwtUserDetails) {
var user = userRepository.findById(jwtUserDetails.getUserId()).orElseThrow(()->new BusinessException("사용자가없습니다"));
var time = jwtUserDetails.getIssueDateTime();
// ZonedDateTime utcZoned = time.atZone(ZoneId.of("UTC"));
// ZonedDateTime kstZoned = utcZoned.withZoneSameInstant(ZoneId.of("Asia/Seoul"));
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd (E) HH:mm", Locale.KOREAN);
String formattedDate = time.format(formatter);
var userInfoDto = UserInfoResponseDto.builder()
.userId(jwtUserDetails.getJwtUserInfoDto().getIdx())
.name(jwtUserDetails.getJwtUserInfoDto().getName())
.userType(jwtUserDetails.getJwtUserInfoDto().getRoles().stream().map(p->UserRoleCode.valueOf(p)).toList())
.issueDateTime(jwtUserDetails.getIssueDateTime().format(formatter))
.build();
return ApiResponseGenerator.success(userInfoDto);
}
public ApiResponse<JwtResultDto> refreshJwt(@Valid UserRefreshJwtRequestDto userRefreshJwtRequestDto, HttpServletResponse response) {
Long idx = jwtService.getUserId(userRefreshJwtRequestDto.getRefreshToken());
var userId = userRepository.findById(idx).orElseThrow(()->new BusinessException("사용자가없습니다")).getUserId();
var userEntity = userRepository.findByUserId(userId).orElseThrow(()->{
var errMap = new HashMap<String, String>();
errMap.put("userId", "아이디를 확인해주세요");
return new BusinessParmException("error", errMap);
});
var roles = userEntity.getUserRoleList().stream().map(p->p.getUserType().getCode()).toList();
var info = JwtUserDto.builder().idx(userEntity.getIdx()).name(userEntity.getName()).roles(roles).build();
String accessToken = jwtService.createAccessToken(info, accessTokenExpTime);
var result = JwtResultDto.builder().accessToken(accessToken).refreshToken(userRefreshJwtRequestDto.getRefreshToken()).build();
return ApiResponseGenerator.success(result);
}
}
package com.example.user;
import ch.qos.logback.core.spi.ErrorCodes;
import com.example.common.response.ApiResponseCode;
import com.example.user.csrf.CustomCsrfTokenRequestHandler;
import com.example.user.jwt.JwtFilter;
import com.example.user.jwt.JwtUserDetailsService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfException;
@Configuration
@EnableWebSecurity
@AllArgsConstructor
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {
private final JwtUserDetailsService jwtUserDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository tokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
http
.csrf(csrf -> csrf.csrfTokenRepository(tokenRepository).csrfTokenRequestHandler(new CustomCsrfTokenRequestHandler()))
.cors(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.addFilterBefore(new JwtFilter(jwtUserDetailsService), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/upload/**").authenticated()
.anyRequest().permitAll() // 그 외 모든 요청은 허용
)
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDeniedHandler())
);
return http.build();
}
@Bean
public AccessDeniedHandler customAccessDeniedHandler() {
return (request, response, accessDeniedException) -> {
if (accessDeniedException instanceof CsrfException) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\": \""+ ApiResponseCode.SYSTEM_ERROR.getCode() + "\"," );
response.getWriter().write("\"message\": \"CSRF token invalid or missing\",");
response.getWriter().write("\"data\": null}");
} else {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("Access Denied");
}
};
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
반응형
'개발 > Spring' 카테고리의 다른 글
| [Spring 실전] 2. 인증 (웹) (0) | 2025.06.19 |
|---|---|
| [Spring 실전] 1. 멀티모듈 (0) | 2025.06.18 |
| Itext 를 이용하여 PDF 에 QR코드 넣기 (0) | 2025.03.11 |
| [Swagger] Request가 Map인 경우 Controller 작성법 (0) | 2023.11.01 |
| Querydsl 에서 datetime과 date 비교하기 (0) | 2023.07.10 |