개발/Spring

[Spring 실전] 3. 인증 (모바일)

희묭 2025. 6. 19. 15:30
반응형

지난 시간에 쿠키를 활용한 웹로그인을 구현했다면 이번엔 모바일에서 사용할 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();
    }
}
반응형