728x90

개요

프로젝트 중 관리자 부분도 하기로 해서 구현해보고자 한다.

 

일단 관리자와 일반 유저를 나눴기 때문에 권한에 따라 접근할 수 있는 경로는 
1. SecurityConfig.java 에서 hasRole or hasAuthority 를 통해 접근 경로를 제한한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",  "/de",      // 메인페이지
            "/signup", // 회원가입 페이지
            "/login", // 로그인 페이지
    };

    // 관리자 페이지
    String[] adminAllowPage = new String[] {
            "/admin",
            "/admin/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .requestMatchers(adminAllowPage).hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
 
    }

코드가 길어져서 불필요한 코드는 삭제했다. 

일단 나는 DB에
ROLE_ADMIN, ROLE_USER 형태로 사용자를 나눠서 저장했기 때문에 hasRole를 사용했다.

 

hasRole  과 hasAuthority 의 차이

  • hasAuthority
    • 권한을 부여할 때 역할이 아니라 권한 자체를 기준으로 접근을 제어
    • 직접 권한을 설정
    • hasAuthority("ROLE_ADMIN") 형태로 권한을 정확하게 명시해줘야 한다.
  • hasRole
    • 내부적으로 ROLE_ 접두사를 자동으로 붙인다.
    •  HTTP 요청 URL 패턴에 대해 접근 권한을 설정
    • 그래서 DB에 ROLE_ADMIN이라고 명시되어 있기 때문에 hasRole("ADMIN")이라고 명시를 한다.
@Controller
@RequiredArgsConstructor
public class AdminController {

    private final UserService userService;
    private final JwtTokenizer jwtTokenizer;

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/userboard")
    public String adminPageAllUserForm(Model model, HttpServletRequest request) {

        model.addAttribute("users", userService.findAllUsers());
        String accessToken = jwtTokenizer.getAccessTokenFromCookies(request);
        if (accessToken != null) {
            String username = jwtTokenizer.getUsernameFromToken(accessToken);
            User user = userService.findByUsername(username).orElse(null);
            model.addAttribute("user", user);
        }
        return "user/admin-all-user";
    }

또한 이미 configure 메서드에서 권한을 제한했지만 더욱 명확하게 하기위해 메서드 호출 전에 다시한번 확인차 

 @PreAuthorize("hasRole('ADMIN')")

어노테이션을 붙여주는 것이다.

  • @PreAuthorize("hasRole('ADMIN')")
    • 주로 메소드에서 사용
    • 특정 메소드에 접근할 수 있는 권한을 세밀하게 설정할 때 사용
    • 이 어노테이션은 메소드 호출 전에 현재 사용자의 권한을 확인하여, 지정된 역할(ADMIN)이 있는지 체크
    • 해당 역할이 없으면 메소드 호출이 거부

 

이렇게 권한을 제한했고 일반 사용자가 "/admin/**") url에 접근할 시 접근이 제한되게 된다. 이제 본격적으로 관리자 페이지를 구현해보겠다.

728x90
728x90

개요

레디스 사용을 안해봤기 때문에 어려울 것 같았는데.. 막상하니 너무나도 쉬웠다. 

레디스 설정은 아래 블로그에서 설정하자.
[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정 :: 미정 (tistory.com)

 

[멋쟁이사자처럼 백엔드 TIL] Docker 및 Redis 설치/설정

* Docker-Desktop을 이용해 MySQL DBMS를 실행프로젝트 진행 중 도커에서 레디스와 mysql 설치 과정에 대해서 정리할려고 한다.일단 나는 도커를 사용해 DB를 사용할려고 한다. 왜 도커를 사용해서 디비를

eesko.tistory.com

 

기존 MySQL을 사용해서 리프레시 토큰을 저장했는데 유효기간이 끝나면 삭제가 되어야 하는데 사진처럼 계속 디비에 남아있는 것을 볼 수 있다. 스케쥴링을 사용해서 매번 지워주는 번거로운 일을 해야 하기 때문에 만약 배포를 하게 되면 너무 번거로울 것 같아서 레디스를 도입하게 되었다. 

레디스의 TTL이라는 기술을 통해 만료시간을 정해주고 만료시간이 지나면 알아서 삭제되도록 구현하도록 하겠다.

 

 

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

 

 

의존성 설치

//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis' 

 

 

 

필요한 환경변수 세팅

spring:
 data:
  redis:
    host: localhost
    port: 6379
    password: 1234

 

 

 

 

RedisConfig 

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class));
        return redisTemplate;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration
                = new RedisStandaloneConfiguration("localhost", 6379);
        redisStandaloneConfiguration.setPassword("1234");
        return new LettuceConnectionFactory(redisStandaloneConfiguration);
    }
}
  • 여기서 host와 post 번호, password는 환경변수 (yml 파일 등)을 통해 안보이게 설정하는 것이 좋다.
  • 나는 개인 프로젝트로 연습용이라 그냥 서버에서 사용했지만 실제로 배포를 하게 되면 환경변수로 설정해서 보안을 위해 안보이게 하는 것이 좋다.

 

 

TokenService.java

@Service
public class RedisTokenService {

    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_REFRESH_TOKEN_KEY_PREFIX = "refreshToken:";

    public RedisTokenService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 리프레시 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 사용자 ID를 값으로 저장합니다.
     * @param refreshToken 리프레시 토큰
     * @param expiration 토큰 만료 시간 (밀리초 단위)
     */
    public void addRefreshToken(String refreshToken, long expiration) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.opsForValue().set(key, refreshToken, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 리프레시 토큰이 유효한지 확인합니다.
     * @param refreshToken 리프레시 토큰
     * @return 유효성 여부
     */
    public boolean isRefreshTokenValid(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        String storedUserId = redisTemplate.opsForValue().get(key);
        return storedUserId != null;
    }

    /**
     * 주어진 리프레시 토큰을 삭제합니다.
     * @param refreshToken 리프레시 토큰
     */
    public void deleteRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        redisTemplate.delete(key);
    }



    /**
     * 주어진 리프레시 토큰에 대해 저장된 사용자 ID를 조회합니다.
     * @param refreshToken 리프레시 토큰
     * @return 저장된 사용자 ID
     */
    public String getRefreshToken(String refreshToken) {
        String key = REDIS_REFRESH_TOKEN_KEY_PREFIX + refreshToken;
        return redisTemplate.opsForValue().get(key);
    }
}
  • 리프레시 토큰을 레디스에 저장하는 로직이다. RedisTemplate를 사용해서 레디스에 저장하는 코드이다.
  • 레디스는 기본적으로 키:값 형태로 저장이 되는데,
  • 키 값으로 REDIS_REFRESH_TOKEN_KEY_PREFIX: {리프레시 토큰 값} 으로 저장을 했고
  • 값으로는 리프레시 토큰 값이 저장되도록 구현했다.

 

@Service
@Slf4j
public class BlackTokenRedisService {
    private final RedisTemplate<String, String> redisTemplate;
    private final String REDIS_BLACKLIST_KEY_PREFIX = "blacklist:";

    public BlackTokenRedisService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 블랙리스트 토큰을 저장합니다. 이때 토큰 자체를 키로 사용하고, 만료 시간을 값으로 저장합니다.
     * @param token 블랙리스트 토큰
     * @param expiration 만료 시간 (밀리초 단위)
     */
    public void addBlacklistedToken(String token, long expiration) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.opsForValue().set(key, token, expiration, TimeUnit.MILLISECONDS);
    }

    /**
     * 주어진 블랙리스트 토큰이 유효한지 확인합니다.
     * @param token 블랙리스트 토큰
     * @return 유효성 여부
     */
    public boolean isTokenBlacklisted(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        String storedToken = redisTemplate.opsForValue().get(key);
        return storedToken != null;
    }

    /**
     * 주어진 블랙리스트 토큰을 삭제합니다.
     * @param token 블랙리스트 토큰
     */
    public void deleteBlacklistedToken(String token) {
        String key = REDIS_BLACKLIST_KEY_PREFIX + token;
        redisTemplate.delete(key);
    }
}
  • 로그아웃시 AccessToken을 Redis의 블랙리스트에 저장한다.
  • 왜냐면 다시 요청이 들어왔을때 더 이상 못쓰는 AccessToken라는 것을 알려줘야하기 때문에..

<진행 로직>

1. 로그인시 Redis에 토큰을 저장함.
2. Redis AccessToken이 저장되어있다는 것은 블랙리스트에 올려져 있다는 것이고 , 사용하면 안됨.
3. 해당 access token redis에 있다면 만료된 토큰인것.
4. 로그아웃 시 Access Token 을 blacklist에 등록하여 만료시키기
5. 로그아웃 시 Redis에 저장된 RefreshToken을 삭제하고 Blacklist Access Token을 등록.
6. 다시 로그인을 할 때 Blacklist 존재하는지 확인 (로그아웃 된 토큰인지)
7. 만약 사용자가 JWt를 통해 인가를 요청했을 시 블랙리스트의 조회를 통해 로그아웃한 JWT 토큰인지 아닌지 판별
 
  • 위와 같은 형태로 진행된다.
  • 즉 로그아웃한 엑세스 토큰은 더이상 사용해서는 안되는 토큰이기 때문에 해킹이 되었을 때 보안에 신경을 쓸 수 있게 된다.
  • 참고로 엑세스 토큰은 인증/인가 전용, 리프레시 토큰은 단순 엑세스 토큰을 재발급 해주는 용도라고 보면 된다.

 

 

Filter

@Slf4j
@RequiredArgsConstructor
public class RedisJwtFilter2 extends OncePerRequestFilter {

    private final JWTUtil jwtUtil;
    private final BlackTokenRedisService blackTokenRedisService;
    private final RedisTokenService redisTokenService;

    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/login", "/api/login", "/de","/api/users/signup","/api/mail/.*","/api/verify-code",
            "/api/check-email","/css/.*","/js/.*", "/api/check-usernick", "/api/refreshToken", "/signup",
            "/oauth2/.*","/login/oauth2/.*", "/api/users/randomNickname", "/files/.*",
            "/api/reset-password", "/api/verify-temporary-password", "/my/change-password",
            "/v3/.*", "/swagger-ui/.*"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestPath = request.getRequestURI();
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = getToken(request);

        if (!StringUtils.hasText(token)) {
            handleMissingToken(request, response);
        } else {
            handleTokenValidation(request, response, token);
        }
        filterChain.doFilter(request, response);
        log.info("토큰이 있는지 확인 : {}", token);
    }

    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
               String storedToken = redisTokenService.getRefreshToken(refreshToken);

                System.out.println(storedToken);

                if (refreshToken.equals(storedToken)) {
                    if (!jwtUtil.isRefreshTokenExpired(refreshToken)) {
                        String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
                        setAccessTokenCookie(response, newAccessToken);
                        getAuthentication(newAccessToken);
                    } else {
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }



    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            if (blackTokenRedisService.isTokenBlacklisted(token)) {
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtUtil.isRefreshTokenExpired(refreshToken)) {
            String newAccessToken = jwtUtil.refreshAccessToken(refreshToken);
            setAccessTokenCookie(response, newAccessToken);
            getAuthentication(newAccessToken);
        } else {
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    private String getToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    private void getAuthentication(String token) {
        Claims claims = jwtUtil.parseAccessToken(token);
        String username = claims.getSubject();
        Long userId = claims.get("userId", Long.class);
        String name = claims.get("name", String.class);
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims);

        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);
        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
        log.info("로그인을 부탁드립니다. --> 토큰이 없어요.");
    }
}
  • 위 코드에서는 달라진 점은 크게 없다. 레디스 블랙리스트에 있는지 보는 부분 말고...

 

 

 

controller.java

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api")
public class RedisController2 {

    private final JWTUtil jwtUtil;
    private final UserServiceImpl userServiceimpl;
    private final PasswordEncoder passwordEncoder;
    private final RedisTokenService refreshTokenService;
    private final BlackTokenRedisService blackTokenRedisService;

    /**
     * 로그인 요청 시 JWT 토큰 발급
     */
    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> login(@RequestBody @Valid UserLoginDto userLoginDto,
                                                      BindingResult bindingResult,
                                                      HttpServletResponse response) {
        log.info("로그인 요청이 들어왔습니다.");
        log.info("아이디 :: {}", userLoginDto.getUsername());
        log.info("비밀번호 :: {}", userLoginDto.getPassword());

        if (bindingResult.hasErrors()) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }

        Optional<User> user = userServiceimpl.findByUsername(userLoginDto.getUsername());

        if (!passwordEncoder.matches(userLoginDto.getPassword(), user .get().getPassword())) {
            return new ResponseEntity("비밀번호가 올바르지 않습니다.", HttpStatus.UNAUTHORIZED);
        }

        List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());

        // 기존 리프레시 토큰 삭제
        refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));

        // 토큰 발급
        String accessToken = jwtUtil.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);
        String refreshToken = jwtUtil.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);

        // 리프레시 토큰을 레디스에 저장
        refreshTokenService.addRefreshToken(refreshToken, jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT);

        // 토큰 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(jwtUtil.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

        // 응답 값
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getId())
                .username(user.get().getUsername())
                .build();

        return new ResponseEntity<>(loginResponseDto, HttpStatus.OK);
    }

    /**
     * 로그아웃 요청
     */
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
                       @CookieValue(name = "refreshToken", required = false) String refreshToken,
                       HttpServletResponse response) {
        log.info("로그아웃 요청이 들어왔습니다.");

        if (accessToken == null) {
            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                log.error("로그아웃 중 오류 발생", e);
            }
            return;
        }

        try {
            // JWT 블랙리스트에 추가
            Date expirationTime = Jwts.parser()
                    .setSigningKey(jwtUtil.getAccessSecret())
                    .parseClaimsJws(accessToken)
                    .getBody()
                    .getExpiration();

            log.info("만료시간: {}", expirationTime);

            // 블랙리스트에 추가
            blackTokenRedisService.addBlacklistedToken(accessToken, expirationTime.getTime() - System.currentTimeMillis());

            SecurityContextHolder.clearContext();

            // 쿠키 삭제
            Cookie accessCookie = new Cookie("accessToken", null);
            accessCookie.setPath("/");
            accessCookie.setMaxAge(0);
            response.addCookie(accessCookie);

            Cookie refreshCookie = new Cookie("refreshToken", null);
            refreshCookie.setPath("/");
            refreshCookie.setMaxAge(0);
            response.addCookie(refreshCookie);

            // 레디스에서 리프레시 토큰 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(refreshToken);
            }

            response.sendRedirect("/");  // 리디렉션 처리
        } catch (IOException e) {
            log.error("로그아웃 후 리디렉션 중 오류 발생", e);
        }
    }
}
  • 여기서 로그인에 성공하면 리프레시 토큰을 레디스에 담아준다.
  • 로그아웃 시 엑세스 토큰은 블랙리스트에 담아준다.

 

 

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomOauth2UserService customOAuth2UserService;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final BlackTokenRedisService jwtBlacklistService;
    private final RedisTokenService refreshTokenService;
    private final CustomSuccessHandler customSuccessHandler;
    private final JWTUtil jwtUtil; //JWTUtil 주입

    public SecurityConfig(CustomUserDetailsService customUserDetailsService, CustomOauth2UserService customOAuth2UserService,
                          AuthenticationConfiguration authenticationConfiguration, CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
                          BlackTokenRedisService jwtBlacklistService, RedisTokenService refreshTokenService,
                          CustomSuccessHandler customSuccessHandler, JWTUtil jwtUtil) {

        this.customUserDetailsService = customUserDetailsService;
        this.customOAuth2UserService = customOAuth2UserService;
        this.authenticationConfiguration = authenticationConfiguration;
        this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
        this.jwtBlacklistService = jwtBlacklistService;
        this.refreshTokenService = refreshTokenService;
        this.customSuccessHandler = customSuccessHandler;
        this.jwtUtil = jwtUtil;
    }

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",      // 메인페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/login", // 로그인 페이지
            "/api/users/signup" // 회원가입 페이지
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable())
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(csrf -> csrf.disable())
                .httpBasic(httpBasic -> httpBasic.disable())
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource configurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("http://localhost:3000"); 
        config.addAllowedOrigin("*"); 
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE")); 
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
  • 여기서도 크게 달라진건 없고 레디스에 저장하기 위해 리프레시토큰, 블랙리스트 서비스로 변경해주면 된다.

 

 

이렇게 기존 RDB를 사용해서 토큰을 저장했는데 레디스를 사용함으로써 만료시간 지정해주고 더욱 서버의 부담을 줄여줄 수 있도록 구현했다.

 

 

 

728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입에 성공 후 비밀번호를 잊어버렸을 시 코드 구현을 하고자 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

1. dto

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class PasswordVerificationRequest {

    private String mail;
    private String tempPassword;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

2. Service

public interface MailService {

    String createTemporaryPassword(String email);

    boolean verifyTemporaryPassword(String email, String tempPassword);

    void sendTemporaryPasswordMail(String email, String tempPassword);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "ch9800113@gmail.com";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 임시 비밀번호 자동 생성 메서드
     */
    private static String generateRandomPassword() {
        int length = 8;
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append((char) (random.nextInt(10) + '0'));
        }
        return sb.toString();
    }

    /**
     * 임시 비밀번호 전송
     */
    @Override
    public void sendTemporaryPasswordMail(String mail, String tempPassword) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("OMG 임시 비밀번호");
            String body = "<h2>OMG에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
            helper.setText(body, true);
            javaMailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("임시 비밀번호 전송 오류", e);
        }
    }

    /**
     * 임시 비밀번호 생성 및 DB 업데이트
     */
    @Override
    public String createTemporaryPassword(String mail) {
        String tempPassword = generateRandomPassword();
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        user.setPassword(passwordEncoder.encode(tempPassword));
        userRepository.save(user);
        return tempPassword;
    }

    /**
     * 임시 비밀번호 검증
     */
    @Override
    public boolean verifyTemporaryPassword(String mail, String tempPassword) {
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        return passwordEncoder.matches(tempPassword, user.getPassword());
    }
}

 

 

[ 코드 설명 ]

/**
 * 임시 비밀번호 자동 생성 메서드
 */
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        sb.append((char) (random.nextInt(10) + '0'));
    }
    return sb.toString();
}
  • 반환 값: String (문자열)
  • 생성 방식: 이 메서드는 8자리의 숫자로 구성된 문자열을 생성하는 메서드이다.
    • 문자열은 StringBuilder를 사용하여 효율적으로 생성되도록 구현했다.
    • 각 반복에서 random.nextInt(10) + '0'을 통해 0부터 9까지의 숫자를 문자로 변환하여 문자열에 추가한다.
  • StringBuilder 사용이유 ::
    • String은 불변 객체(immutable object)이다. 즉 한 번 생성된 String은 변경할 수 없으며, 문자열의 조작은 새로운 String 객체를 생성하여 처리된다.
    • StringBuilder를 사용하여 문자열을 생성한 후, 최종적으로 toString() 메서드를 호출하여 불변의 String 객체를 반환하도록 구현했다.
    • 위의 코드는 숫자로만 구성했지만, 나중에 보안을 위해 아래처럼 작성하는 것으로 바꾸었다.
  •  
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for (int i = 0; i < length; i++) {
        sb.append(characters.charAt(random.nextInt(characters.length())));
    }

    return sb.toString();
}
/**
 * 임시 비밀번호 전송
 */
@Override
public void sendTemporaryPasswordMail(String mail, String tempPassword) {
    MimeMessage message = javaMailSender.createMimeMessage();
    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("임시 비밀번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
        helper.setText(body, true);
        javaMailSender.send(message);
    } catch (MessagingException e) {
        throw new RuntimeException("임시 비밀번호 전송 오류", e);
    }
}
  • 반환 값: void
  •  생성 방식 : 이 메서드는 임시비밀번호를 이메일로 전송하는 기능만 수행하고, 결과를 반환할 필요가 없다
    • javaMailSender.send(message); 를 통해 메서드에서 바로 구현하여 바로 메일을 전송하였다.
  • 하지만 저번 포스트에서 회원가입시 이메일 인증 번호 전송 로직을 보면
@Override
public MimeMessage createMail(String mail){
    createNumber(mail);
    MimeMessage message = javaMailSender.createMimeMessage();

    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("이메일 인증번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
        helper.setText(body, true);
    } catch (MessagingException e) {
        e.printStackTrace();
    }

    return message;
}

/**
 * createMail() 메서드의 내용을 이메일 전송
 */
@Async
@Override
public CompletableFuture<Integer> sendMail(String mail) {
    MimeMessage message = createMail(mail);
    javaMailSender.send(message);
    return CompletableFuture.completedFuture(verificationCodes.get(mail));
}
  • 여기서 코드의 반환값은 MimeMessage 이다.
  • 즉 javaMailSender.send(message); 를 하지 않았기 때문에 따로 sendMail 메서드를 통해 이메일 전송을 해줘야 한다.
  • MimeMessage  란? ::
    • MimeMessage  객체는 이메일 메시지를 생성하고 설정하는 데 사용되는 객체이다.
    • 발신자, 수신자, 제목, 본문 내용, 첨부 파일 등 이메일의 모든 구성 요소를 설정할 수 있고 설정된 MimeMessage 객체는 JavaMailSender를 통해 이메일 서버로 전송해야 한다.
  • 나중에 코드 통일성을 위해 하나의 메서드에서 전송될 수 있도록 구현할 예정이다.

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 임시 비밀번호 재발급 발송 메서드
     */
    @PostMapping("/api/users/reset-password")
    public ResponseEntity<String> resetPassword(@RequestBody MailRequest mailRequest) {
        String email = mailRequest.getMail();

        if (userService.existsByUsername(email)) {
            String tempPassword = mailService.createTemporaryPassword(email);
            mailService.sendTemporaryPasswordMail(email, tempPassword);
            return ResponseEntity.ok("임시 비밀번호가 이메일로 발송되었습니다.");
        } else {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("해당 이메일로 가입된 사용자가 없습니다.");
        }
    }

    /**
     * 임시 비밀번호 검증 메소드
     */
    @PostMapping("/api/users/verify-temporary-password")
    public ResponseEntity<String> verifyTemporaryPassword(@RequestBody PasswordVerificationRequest request) {
        boolean isVerified = mailService.verifyTemporaryPassword(request.getMail(), request.getTempPassword());
        return isVerified ? ResponseEntity.ok("Verified") : ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Verification failed");
    }
}
/**
 * 비밀번호 재발급 페이지 이동
 */
@GetMapping("/users/reset-user-password")
public String showResetPasswordForm() {
    return "/user/findPassword";
}

 

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>비밀번호 재발급</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/find-password.css">
</head>
<body>
<form id="emailVerificationForm">

    <h1>비밀번호 재발급</h1>
    <label for="email">가입 이메일</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>
    <button type="button" id="send-code-button">임시 비밀번호 발송</button>
    <span id="emailCheckMessage" class="error"></span>

    <div id="verifyCodeSection">
        <label for="temporaryPassword">임시 비밀번호</label>
        <input type="text" id="temporaryPassword" name="temporaryPassword" placeholder="임시 비밀번호 입력" required />
        <button type="button" id="verify-temporary-password-button">임시 비밀번호 확인</button>
        <span id="verificationMessage" class="error"></span>
    </div>
</form>
<script>
    $(document).ready(function() {
        $('#verifyCodeSection').hide();

        $('#send-code-button').on('click', function() {
            let email = $('#email').val();
            $.ajax({ // 이메일이 데이터베이스에 있는지 확인
                url: '/api/users/check-email',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email }),
                success: function(response) {
                    if (response) {
                        $.ajax({ // 이메일이 존재하면 임시 비밀번호 발송
                            url: '/api/users/reset-password',
                            type: 'POST',
                            contentType: 'application/json',
                            data: JSON.stringify({ mail: email }),
                            success: function(response) {
                                $('#verifyCodeSection').slideDown(); // 인증 코드 입력 섹션 표시
                                alert('임시 비밀번호가 이메일로 발송되었습니다. 이메일을 확인해주세요.');
                            },
                            error: function(error) {
                                alert('임시 비밀번호 발송에 실패했습니다. 다시 시도해주세요.');
                            }
                        });
                    } else {
                        $('#emailCheckMessage').text('해당 이메일로 가입된 사용자가 없습니다.').show();
                    }
                },
                error: function(error) {
                    alert('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.');
                }
            });
        });

        $('#verify-temporary-password-button').on('click', function() {
            const email = $('#email').val();
            const tempPassword = $('#temporaryPassword').val();
            $.ajax({
                url: '/api/users/verify-temporary-password',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email, tempPassword: tempPassword }),
                success: function(response) {
                    if (response === "Verified") {
                        alert("임시 비밀번호가 확인되었습니다. 로그인하세요.");
                        window.location.href = "/signin";
                    } else {
                        $('#verificationMessage').text("임시 비밀번호가 일치하지 않습니다. 다시 시도하세요.").show();
                    }
                },
                error: function(xhr, status, error) {
                    alert("임시 비밀번호 검증에 실패했습니다. 다시 시도하세요.");
                }
            });
        });
    });
</script>
</body>
</html>
  1. 해당 이메일이 디비에 있는지 확인한다.
  2. 이메일이 존재한다면 해당 이메일로 임시 비밀번호를 전송해준다.
  3. 이때 폼에서는 숨겼던 섹션을 표시하고 임시비밀번호를 입력 란을 활성화한다.
  4. 임시비밀번호가 이메일로 전송한 값과 같은지 확인한다.
  5. 이때 임시비밀번호가 디비에 업데이트가 되어서 기존 비밀번호로 인증을 할 수 없게 된다.

 

여기까지 끝-!

 

 

 

 

 


jQuery에서 HTML 요소를 표시하는 두 가지 방법

 

  • jQuery 에서 아래의 두가지가 섹션을 보여주는 방식의 차이라서 정리한다.
// 애니메이션 효과로 서서히 표시
$('#verifyCodeSection').slideDown();

// 즉시 표시
$('#verifyCodeSection').show();

 

 

 

728x90
728x90

 

개요

이번에는 인증/인가 총 정리다. 

OAuth2 로그인과 JWT를 활용한 보안 방식을 정리할 예정이다.

다른 포스터에 이미 올렸지만 정리를 위해 처음부터 정리할 예정

총 몇편으로 나올지 궁금하다.

 


일단 jwt 방식을 위한 기본 설정을 한다. OAuth2 설정도 같이 하면 복잡해져서 뒤에서 정리할 예정이다.

 

 

1. 엔티티 설정

  • 유저 엔티티
@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username; // 이메일

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name; // 본명

    @Column(nullable = false)
    private String usernick; // 닉네임

    @Column(nullable = false)
    private LocalDateTime registrationDate = LocalDateTime.now(); // 가입일

    @Column(nullable = false)
    private LocalDate birthdate; // 생일

    @Column(nullable = false)
    private String gender; // 성별

    @Column(nullable = false, length = 50)
    private String phoneNumber; // 연락처

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();

    private String provider; // oauth2 플랫폼
    private String providerId; // 플랫폼 아이디
}
  • 롤 엔티티
@Entity
@Table(name = "roles")
@Getter@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    public Role(String name) {
        this.name = name;
    }
}
  • 리프레시 토큰 엔티티
@Entity
@Table(name = "refresh_token")
@Getter@Setter
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "value", length = 512)
    private String value;

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "expiration_time")
    private String expiration;
}
  • 로그아웃 토큰 엔티티
@Entity
@Table(name = "jwt_blacklist")
@Getter@Setter
@NoArgsConstructor
public class JwtBlacklist {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "token", length = 512)
    private String token;

    @Column(name = "expiration_time")
    private Date expiration;

    public JwtBlacklist(String token, Date expiration) {
        this.token = token;
        this.expiration = expiration;
    }
}

 

 

2. 레포지토리 설정

  • 유저 레포지토리
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username); // 아이디 찾기

    boolean existsByUsernick(String userName); // 닉네임 중복 확인
    
    boolean existsByUsername(String email); // 이메일(아이디) 중복 확인
}
  • 롤 레포지토리
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    Optional<Role> findByName(String name);
}
  • 리프레시 토큰 레포지토리
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByValue(String value);

    boolean existsByValue(String token);

    Optional<RefreshToken> findByUserId(Long userId);

    @Transactional
    void deleteByValue(String refresh);
}
  • 로그아웃 토큰 레포지토리
public interface JwtBlacklistRepository extends JpaRepository<JwtBlacklist, Long> {
    boolean existsByToken(String token);
}

 

 

 

 

 

3. DTO 설정

  • 회원가입 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserSingUpDto {

    private Long id;

    //@NotBlank(message = "아이디는 필수 입력 값입니다.")
    private String username; //이메일 ==> id
    private String name; // 사용자 이름

    //@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
    //@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
    private String password;
    private String passwordCheck;

    //@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
    private String usernick;
    private LocalDate birthdate; // 생년월일
    private String gender;
    private String phoneNumber;
}
  • 유저 정보 수정 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEditDto {

    private String usernick; // 닉네임
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
    private String phoneNumber; // 연락처
}
  • 유저 로그인 DTO
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginDto {
    @NotEmpty
    private String username;

    @NotEmpty
    private String password;
}
  • 로그인 응답 DTO
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginResponseDto {
    private String accessToken;
    private String refreshToken;
    private Long userId; // private Long userId;
    private String username;
}

 

 

4. 서비스 설정

Service Layer Pattern 을 사용했다.
Service 계층의 인터페이스와 비즈니스 로직을 분리했다.

  • 유저서비스
@Service
public interface UserService {

    Optional<User> findById(Long id); // 유저 아이디 찾기

    void signUp(UserSingUpDto userSingUpDto); // 회원가입

    Optional<User> findByUsername(String email); // 아이디 찾기

    void deleteUser(String username); // 유저 삭제

    boolean existsByUsername(String username); // 아이디 중복 확인

    boolean existsByUsernick(String usernick); // 닉네임 중복 확인

    Optional<User> updateUser(String username, UserEditDto userEditDto); // 유저 정보 업데이트
}
  • 유저서비스 impl
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final RoleRepository roleRepository;

    @Override
    public Optional<User> findById(Long id) {
        return userRepository.findById(id);
    }

    @Override
    public Optional<User> findByUsername(String username) {
        return userRepository.findByUsername(username);
    }

    @Override
    public void signUp(UserSingUpDto userSingUpDto) {
        if (!userSingUpDto.getPassword().equals(userSingUpDto.getPasswordCheck())) {
            throw new RuntimeException("비밀번호가 다릅니다.");
        }
        if (userRepository.existsByUsername(userSingUpDto.getUsername())) {
            throw new RuntimeException("이메일이 존재합니다.");
        }
        if (userRepository.existsByUsernick(userSingUpDto.getUsernick())) {
            throw new RuntimeException("닉네임이 존재합니다.");
        }

        Role role = roleRepository.findByName("ROLE_USER")
                    .orElseThrow(() -> new RuntimeException("User 역할이 없습니다."));

        User user = new User();
        user.setRoles(Collections.singleton(role)); // 단일 역할
        user.setUsernick(userSingUpDto.getUsernick()); // 닉네임
        user.setUsername(userSingUpDto.getUsername()); // email
        user.setName(userSingUpDto.getName()); // 이름
        user.setPassword(passwordEncoder.encode(userSingUpDto.getPassword()));
        user.setRegistrationDate(LocalDateTime.now());
        user.setBirthdate(userSingUpDto.getBirthdate());
        user.setGender(userSingUpDto.getGender());
        user.setPhoneNumber(userSingUpDto.getPhoneNumber());

        userRepository.save(user);
    }

    @Override
    public void deleteUser(String username) {
        Optional<User> usernameOptional = userRepository.findByUsername(username);
        if (usernameOptional.isPresent()) {
            userRepository.delete(usernameOptional.get());
        } else {
            throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
        }
    }
    
    /**
     * 사용자 페이지 수정
     */
    @Override
    public Optional<User> updateUser(String username, UserEditDto userEditDto) {

        Optional<User> userOptional = userRepository.findByUsername(username);
        if (userOptional.isEmpty()) {
            log.error("사용자 없습니다. :: {}", username);
            return Optional.empty();
        }

        User user = userOptional.get();

        user.setUsernick(userEditDto.getUsernick());
        user.setBirthdate(userEditDto.getBirthdate());
        user.setGender(userEditDto.getGender());
        user.setPhoneNumber(userEditDto.getPhoneNumber());

        User updatedUser = userRepository.save(user);
        return Optional.of(updatedUser);
    }


    @Override
    public boolean existsByUsername(String username) {
        return userRepository.existsByUsername(username);
    }

    @Override
    public boolean existsByUsernick(String usernick) {
        return userRepository.existsByUsernick(usernick);
    }
}
  • 리프레시토큰 서비스
public interface RefreshTokenService {

    RefreshToken addRefreshToken(RefreshToken refreshToken);
    
    void deleteRefreshToken(String refreshToken);

    boolean isRefreshTokenValid(String refreshToken);
}
  • 리프레시토큰 impl
@Service
@RequiredArgsConstructor
public class RefreshTokenServiceImpl implements RefreshTokenService {
    
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    @Override
    public RefreshToken addRefreshToken(RefreshToken refreshToken) {
        return refreshTokenRepository.save(refreshToken);
    }

    @Override
    public void deleteRefreshToken(String refreshToken) {
        refreshTokenRepository.findByValue(refreshToken).ifPresent(refreshTokenRepository::delete);
    }

    @Override
    public boolean isRefreshTokenValid(String refreshToken) {
        return refreshTokenRepository.existsByValue(refreshToken);
    }
}
  • 로그아웃토큰 서비스
public interface JwtBlacklistService {

    void save(JwtBlacklist blacklist);

    boolean isTokenBlacklisted(String token);
}
  • 로그아웃토큰 impl
@Service
@RequiredArgsConstructor
public class JwtBlacklistServiceImpl implements JwtBlacklistService {
    
    private final JwtBlacklistRepository jwtBlacklistRepository;

    @Override
    public void save(JwtBlacklist blacklist) {
        jwtBlacklistRepository.save(blacklist);
    }

    @Override
    public boolean isTokenBlacklisted(String token) {
        return jwtBlacklistRepository.existsByToken(token);
    }
}

 

 

기본적인 틀은 이렇게 잡고 간다.

728x90
728x90

개요

이번에는 구글 OAuth2 로그인에 대해서 작성해볼려고 한다. 

 

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver :: 미정 (tistory.com)

 

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver

개요시큐리티를 사용해 OAuth2 로그인을 진행하고자 한다. 나는 그중 카카오와 네이버에 대해 해보겠다.앞에서 진행한 코드를 그대로 사용해서 이어서 진행한다. Spring Security + Session 를 사용한

eesko.tistory.com

위 코드를 그대로 사용할 예정이다.

 

구글 소셜 로그인 신청

일단 구글 관련해서는 다루지 않을 것이고 이 부분은 다른 블로그를 찾아보면 된다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6

oauth2

 

 

프로젝트 파일 구조

 

의존성 설치

  • Build.gradle
	// OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
  • Build.gradle 에 security와 OAuth2 의존성을 추가해준다.

 

  • Application.yml
spring:
  application:
    name: [projectname]

  datasource:
    url: jdbc:mysql://localhost:3306/[db]
    username: [usernmae]
    password: [password]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: [client-id]
            client-secret: [client-secre]
            client-name: google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/google
            scope:
              - profile
              - email
server:
  port: 8080
  • application.yml 파일에 OAuth2 설정을 해준다. 
  • 구글같은 경우 provider 부분은 스프링에서 알아서 제공해줘서 작성안해줘도 된다.

 


1. GoogleResponse

getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google

구글 같은 경우 응답 방식인 response 가 위와 같이 나온다. 저거에 맞게 커스텀하면 된다.

public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {

        this.attribute = attribute;
    }

    @Override
    public String getProvider() {

        return "google";
    }

    @Override
    public String getProviderId() {

        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {

        return attribute.get("email").toString();
    }

    @Override
    public String getName() {

        return attribute.get("name").toString();
    }


    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

각 소셜 플랫폼마다 웅답 방식이 다르기 때문에 인터페이스를 구현했고 구글 로그인을 위해 내가 만든 인터페이스를 상속하는 방식으로 진행했다. 이 부분은 이전 게시글을 보면 된다.

 

 

 

2. OAuth2UserService 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println("OAuth2User attributes: " + oAuth2User.getAttributes());
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;

        switch (registrationId) {
            case "naver":
                log.info("naver 로그인");
                oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
                break;

            case "kakao":
                log.info("kakao 로그인");
                oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
                break;

            case "google":
                log.info("google 로그인");
                oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
                break;

            default:
                log.error("로그인 실패: 지원하지 않는 로그인 제공자입니다. 등록 ID: {}", registrationId);
                throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }


        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();

        String name = provider + " " + providerId; // 이렇게 해서 해당 유저가 이미 디비에 있는지 없는지 확인
        Optional<User> userOptional = userRepository.findByUsername(name);

        // "USER" 라는 역할을 OAuth2 로그인 사람에게 다 부여
        String roleName = "ROLE_USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName); // 디비에서 찾아오는데,
        Role role;
        if (roleOptional.isEmpty()) { // "USER" 디비에 없다면
            role = new Role(roleName); // 새로운 역할 등록
            role = roleRepository.save(role);
        } else {
            role = roleOptional.get(); // 그게 아니라면 역할 꺼내오기
        }

        String password = String.valueOf(UUID.randomUUID());

        User user;
        // OAuth2 로그인을 한 적 없는 사람
        if (userOptional.isEmpty()) {
            user = User.builder()
                    .name(oAuth2Response.getEmail())
                    .username(name)
                    .roles(Set.of(role))
                    .providerId(oAuth2Response.getProviderId())
                    .provider(oAuth2Response.getProvider())
                    .password(password)
                    .phoneNumber("default")
                    .birthdate(LocalDate.from(LocalDateTime.now()))
                    .gender("default")
                    .registrationDate(LocalDateTime.now())
                    .usernick(oAuth2Response.getName())
                    .build();
            userRepository.save(user);
        } else { // 이미 OAuth2 로그인을 한 적이 있는 사람
            user = userOptional.get();
            boolean updated = false;

            if (!user.getRoles().contains(role)) {
                user.getRoles().add(role);
                updated = true;
            }

            // 닉네임은 첫 로그인 이후 마이페이지에서만 변경 가능
            if (!user.getUsernick().equals(oAuth2Response.getName()) && user.getUsernick() == null) {
                user.setUsernick(oAuth2Response.getName());
                updated = true;
            }

            if (updated) {
                userRepository.save(user);
            }
        }

        System.out.println("User saved: " + user);

        // 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
        // 로그인 한적 없는 사람은 "USER" 역할, 기존에 한 적있다면 그 사람이 현재 갖고 있는 역할을 CustomOAuth2User 클래스로 반환
        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser 메서드는 네이버나 카카오 등 소셜플랫폼의 사용자 인증 정보를 받아오는 메서드이다.
  • 위 클래스는 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

3. 로그인 폼 및 로그인 한 회원의 정보를 보여주기

  • loginform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/kakao">카카오 로그인</a>
<a href="/oauth2/authorization/naver">네이버 로그인</a>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>

 

이렇게 하면 기본 Oauth2 세션 로그인은 성공할 것이다. 시큐리티 관련 설정은 이전 게시글에서 다뤘기 때문에 여기서 따로 적지는 않을 것이다.

다음 포스터에서는 모든 소셜로그인에서  jwt를 사용해서 구현해볼 것이다.

 

728x90
728x90

개요

시큐리티를 사용해 OAuth2 로그인을 진행하고자 한다. 나는 그중 카카오와 네이버에 대해 해보겠다.

앞에서 진행한 코드를 그대로 사용해서 이어서 진행한다. 

Spring Security + Session 를 사용한 회원가입, 로그인, 로그아웃 구현 :: 미정 (tistory.com)

 

OAuth2 에 대한 설명은 

Spring : OAuth2 로그인 방식 :: 미정 (tistory.com) 

 

Spring : OAuth2 로그인 방식

OAuth 2.0 이란?서비스에서 사용자 개인정보와 인증에 대한 책임을 지지 않고 신뢰할 만한 타사 플랫폼에 위임개인정보 관련 데이터를 직접 보관하는 것은 리스크가 크며 보안적으로 문제되지 않

eesko.tistory.com

이걸 보면 된다.

 

 

 

네이버, 카카오 소셜 로그인 신청

나는 네이버와 카카오에 대해서 신청을 했고

이 분의 영상을 보고 네이버에 대한 신청은 했다. 

https://youtu.be/L8yAtjjOhDo?feature=shared

 

카카오 신청은 이 분의 글을 보고 신청 했다.

[Spring Boot] 로그인 기능 구현 (6) - 카카오 로그인 (OAuth 2.0) — 공대생의 코딩 일기 (tistory.com)

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6

oauth2

 

 

프로젝트 파일 구조

 

 

 

의존성 설치

  • Build.gradle
	// OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

    // security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security와 OAuth2 의존성을 추가해준다.

 

  • Application.yml
spring:
  application:
    name: [projectname]

  datasource:
    url: jdbc:mysql://localhost:3306/[db]
    username: [usernmae]
    password: [password]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: [설정]
            client-secret: [설정]
            scope:
              - account_email
              - profile_nickname
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-name: Kakao
            client-authentication-method: client_secret_post

          naver:
            client-id: [설정]
            client-secret: [설정]
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            client-name: Naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email

        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

server:
  port: 8080
  • application.yml 파일에 OAuth2 설정을 해준다. 
  • 위에서 신청한 네이버와 카카오 설정들을 맞게 설정해준다.

 


1. 엔티티 수정

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String usernick;

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
    
    /**
     * Oauth2 관련
     */
    // providerId : 구굴,카카오,네이버 로그인 한 유저의 고유 ID가 들어감
    private String loginId; // Unique identifier for OAuth users

    // provider : google, kakao, naver 이 들어감
    private String provider;
}
  • 아래 2개의 칼럼을 추가해준다.
  • 그리고 위의 엔티티는 최종이고 아래 부분은 이전 엔티티에서 닉네임 컬럼 부분을 보면 usernick이 중복 불가능하게 했지만 그 부분을 수정해줬다.
  • 하지만 현재 디비에 값들이 저장된 상태라서 엔티티에서 변경을 한후 sql 구문을 통해 수정해줬다.
// 변경 전
@Column(nullable = false, unique = true)
private String usernick;

// 변경 후 
@Column(nullable = false)
private String usernick;
# 1 
SHOW INDEX FROM users WHERE Column_name = 'usernick';

# 2 Key_name 의 값을 확인

# 3
ALTER TABLE users DROP INDEX UK5rqu0cqcxxar2go1qlddeia02;

 

2. Oauth2 응답 객체

  • OAuth2Response.java
public interface OAuth2Response {

    //제공자 (Ex. naver, google, ...)
    String getProvider();

    //제공자에서 발급해주는 아이디(번호)
    String getProviderId();

    //이메일
    String getEmail();

    //사용자 실명 (설정한 이름)
    String getName();
}
  • 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 인터페이스를 만들어 구별한다.
  • 카카오, 구글, 네이버, 깃허브 등등 이 인터페이스를 상속받아 각각의 규격에 맞게 구현한다.
  • 현재 나는 사용자 이메일, 설정 이름만 가져왔고 프로필 이미지, 생일 등등 가져올 수 있다.

 

Q. 여기서 잠깐!

  • 네이버와 카카오, 구글의 사용자 정보 response 를 보자.
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
  • 각 사이트마다 출력 값들이 다르다.
  • 그러기 때문에 기본적인 형태가 다르기 때문에 위에서 만든 OAuth2Response 인터페이스를 만들어서 네이버, 카카오 응답 객체에 맞게 고칠 수 있도록 기본적인 틀을 만들어 둔다.
  • 그럼 카카오, 네이버, 구글 등 OAuth2Response 인터페이스를 상속받아서 구현하면 된다.

 

 

  • KakaoResponse.java
getAttributes : 
{
	id=12345678, 
	connected_at=2024-07-26T09:56:54Z, 
	properties={
    	nickname=엉이
    }, 
	kakao_account={
    	profile_nickname_needs_agreement=false, 
                             profile={nickname=엉이, is_default_nickname=false},
                             has_email=true, 
                             email_needs_agreement=false, 
                             is_email_valid=true, 
                             is_email_verified=true, 
                             email=엉이@kakao.com
    }
}

 provider : kakao

위의 출력값을 보면서 아래 코드를 이해하자.

public class KakaoResponse implements OAuth2Response{

    private Map<String, Object> attribute;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("Kakao attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
        return properties.get("nickname").toString();
    }
}
  • 카카오는 출력 데이터 내부 안에 kakao_account 라는 데이터가 존재하고 그 안에 사용자 이메일, 이름 등에 대한 값이 있기 때문에 get()으로 값을 꺼내온다.

 

 

 

  • NaverResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
    	response={
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : naver

위의 출력값을 보면서 아래 코드를 이해하자.

public class NaverResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
        System.out.println("naver attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}
  • 네이버는 데이터 내부 안에  response 라는 데이터가 또 있고 그 안에 사용자의 정보에 대한 값이 저장되어 있기 때문에 get으로 가져와준다.

 

 

  • googleResponse.java
getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google
public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("google attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }
}

구글에 대해서는 카드 인증이 필요해서 귀찮기 때문에.. 하지는 않았지만 정리를 위해 작성했다. 실제 구글 oauth2는 신청하지 않았기에 사용하지 않았다.

 

 

 

3. OAuth2User

  • CustomOAuth2User.java
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

	// 현재는 null 했지만 이 값을 지정하면 
    // 카카오,네이버 등 로그인했으면 거기에 맞는 프로필 사진이나 가져올 수 있음
    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

	// role에 해당하는 값
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(role));
        return collection;
    }
    
    @Override
    public String getName() {
        return oAuth2Response.getName();
    }

	// 사용안함
    public String getUsername() {
        return oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
    }
}
  • 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다. 
  • 이 클래스를 통해 특정 값과 롤에 대해 정의한다.

 

4. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
    private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login").permitAll()
                        .requestMatchers("/oauth-login/admin").hasRole("ADMIN")
                        .requestMatchers("/oauth-login/info").authenticated()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable())
                .cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource()))
                // OAuth 등록
               .oauth2Login(oauth2 -> oauth2
                        .loginPage("/api/users/login") // 커스텀 로그인 방식 지정
                        // customOAuth2UserService 등록
                        .userInfoEndpoint(userInfoEndpointConfig ->
                                userInfoEndpointConfig.userService(customOAuth2UserService))
                        .failureUrl("/loginFailure")
                        .defaultSuccessUrl("/api/users/info")
                        .authorizationEndpoint(authorization -> authorization
                                .baseUri("/oauth2/authorization")
                        )
                        .permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET","POST","DELETE"));
        source.registerCorsConfiguration("/**",config);
        return source;
    }
}
  • Oauth2 설정을 해준다.

 

 

5. OAuth2UserService 구현

  • OAuth2UserService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {
    // DefaultOAuth2UserService는 OAuth2UserService의 구현체라서 
    // DefaultOAuth2UserService 또는 OAuth2UserService 아무거나 상속해도 된다.

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
	
    // loadUser --> 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드
    // userRequest 를 통해 카카오, 네이버, 구글 등등 인증 데이터가 넘어 올 것이다.
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);

        System.out.println("OAuth2User attributes: " + oAuth2User.getAttributes());
  		
        // 네이버, 구글, 깃허브 등등 어떤 어떤 인증 값인지 구별하기 위해 인증 제공자를 구분
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        
        // 각 사이트마다 인증 데이터 규격이 다르기 때문에 OAuth2Response 를 만들어 구별
        // 인증 제공자에 따라 OAuth2Response를 생성
        OAuth2Response oAuth2Response = null;
        
        // 그 값이 네이버면
        if (registrationId.equals("naver")) {
            log.info("naver 로그인");
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
      	// 그 값이 카카오면
        } else if (registrationId.equals("kakao")) {
            log.info("kakao 로그인");
            oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        // 둘다 아니면
        } else {
            System.out.println("로그인 실패");
            throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }

        // 데이터 베이스 저장 관련 구현
        
        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();

        String username = provider + " " + providerId;
        Optional<User> userOptional = userRepository.findByUsername(username);

        String roleName = "USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName);
        Role role;
        if (roleOptional.isEmpty()) {
            role = new Role(roleName);
            role = roleRepository.save(role);  // Save the new role and get the persisted instance
        } else {
            role = roleOptional.get();
        }

        User user;
        if (userOptional.isEmpty()) {
            user = User.builder()
                    .username(username)
                    .email(oAuth2Response.getEmail())
                    .roles(Set.of(role))
                    .loginId(oAuth2Response.getProviderId())
                    .provider(oAuth2Response.getProvider())
                    .password("defaultPassword")
                    .registrationDate(LocalDateTime.now())
                    .usernick(oAuth2Response.getName())
                    .build();
            userRepository.save(user);
        } else {
            user = userOptional.get();
            user.setUsername(username);
            user.setEmail(oAuth2Response.getEmail());
            user.setLoginId(oAuth2Response.getProviderId());
            user.setProvider(oAuth2Response.getProvider());
            user.getRoles().add(role);
            user.setRegistrationDate(LocalDateTime.now());
            user.setUsernick(oAuth2Response.getName());
            userRepository.save(user);
            roleName = user.getRoles().iterator().next().getName();
        }

        System.out.println("User saved: " + user);

		// 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
        // 인증된 사용자 정보를 반환
        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
  • 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

6. 컨트롤러

  • UserController.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/api/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }

    // oauth2 유저의 정보 가져오기
    @GetMapping("/info")
    public String info(Model model, Authentication authentication) {
        CustomOAuth2User userDetails = (CustomOAuth2User) authentication.getPrincipal();

        model.addAttribute("name", userDetails);
        return "/user/info";
    }
}
  • 해당 컨트롤러에서 /api/users/info앤트포인트의 값으로 화면이 보여질 것이다.

 

 

7. 로그인 폼 및 로그인 한 회원의 정보를 보여주기

  • loginform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/kakao">카카오 로그인</a>
<a href="/oauth2/authorization/naver">네이버 로그인</a>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • info.html
  • OAuth2 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<h1> 당신은 OAUth2 로그인을 하셨습니다. </h1>
<p>안녕하세요, <span th:text="${name}"></span>님</p>
<p>안녕하세요, 당신이 로그인한 플랫폼 : <span th:text="${name.username}"></span>님</p>
<p>안녕하세요, 플랫폼 이름 : <span th:text="${name.name}"></span>님</p>
<p>안녕하세요, 당신의 등급 :  <span th:text="${name.authorities}"></span>님</p>
</body>
</html>
  • home.html
  • 일반 로그인 후 보여지는 화면이다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>메인 홈 입니다.</title>
</head>
<body>
<h2>로그인에 성공하셨어요 축하합니다.</h2>
<p>안녕하세요, 로그인 : <span th:text="${user.email}"></span>님</p>
<p>이름: <span th:text="${user.username}"></span></p>
</body>
</html>

 

이렇게 하면 기본 Oauth2 세션 로그인은 성공할 것이고 이제 여기에 jwt를 사용해서 구현해볼 것이다.

728x90
728x90

 

개요

개인 프로젝트를 진행하는 중 시큐리티 부분은 아주 중요하고 헷갈리기 때문에 정리해놓기 위해 포스팅 하기로 했다.

일단 이 부분은 이전에 작성한 jwt를 사용하지 않는 기본적인 세션방식의 시큐리티 로그인이다. 둘의 차이를 명확하게 이해하기 위해 작성한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

 

프로젝트 파일 구조

 

 

 

의존성 설치

	// security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    //타임리프에서 스프링시큐리티를 쓸수있게.
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
    implementation 'org.jsoup:jsoup:1.14.3'

    //validation사용
    implementation 'jakarta.validation:jakarta.validation-api:3.0.2'
  • Build.gradle 에 security 의존성을 추가해준다.
spring:
  application:
    name: testsecu

  datasource:
    url: jdbc:mysql://localhost:3306/[db이름]
    username: [자신의 아이디]
    password: [자신의 비밀번호]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

server:
  port: 8080
  • application.yml 파일에 mysql, jpa 설정을 해준다.

 

INSERT INTO roles (id, name) VALUES (1, 'ADMIN');
INSERT INTO roles (id, name) VALUES (2, 'USER');
  • 그리고 role 엔티티에 위에처럼 미리 값을 집어넣어 준다.

 

 

 

1. 엔티티 생성

  • User.java
@Entity
@Table(name = "users")
@Getter @Setter
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String username;

    @Column(unique = true, nullable = false)
    private String usernick;

    @Column(name = "registrationDate", nullable = false)
    private LocalDateTime registrationDate  = LocalDateTime.now();

    private String filename;
    private String filepath;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
    joinColumns = @JoinColumn(name = "user_id"),
    inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();
}
  • 일단 나는 이메일과 비밀번호로 로그인을 할 것이다.
  • 이메일(아이디), 비밀번호, 사용자이름, 닉네임, 가입일자는 필수고 나중에 사진 등록을 위한 파일경로,파일이름 칼럼도 추가해줬다.

 

  • Role.java
@Entity
@Table(name = "roles")
@Getter @Setter
public class Role {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;
}

 

 

 

2. DTO 설정

  • UserDto.java
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {

    private Long id;
    private String email;
    private String password;
    private String passwordCheck;
    private String username;
    private String usernick;

    public static UserDto of(User user) {
        UserDto userDto = new UserDto();
        userDto.setId(user.getId());
        userDto.setEmail(user.getEmail());
        userDto.setPassword(user.getPassword());
        userDto.setUsername(user.getUsername());
        userDto.setUsernick(user.getUsernick());
        return userDto;
    }
}
  • User 엔티티에서 회원가입에 필요한 DTO를 하나 만들어서 코드를 더욱 깔끔하게 구현했다.

 

 

3. Repository 설정

  • UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
    Optional<User> findByUsername(String username);

    boolean existsByUsernick(String userName);
    boolean existsByEmail(String email);
}
  • 이메일로 로그인을 할것이기 때문에 findByEmail이라는 메서드가 필요하고,
  • findByUsername은 관리자가 회원가입할 때 역할을 분리하기 위한 메서드이다.

 

  • RoleRepository.java
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    Role findByName(String name);
}

 

 

4. Service 설정

  • UserService.java
@Service
public interface UserService {

    void signUp(UserDto userDto);

    Optional<User> findByEmail(String email);

    void deleteUser(String email);

}
  • 원래의 나라면 Service는 Class로 만들었을 테지만, 팀원 중 한명이 유지보수에 좋다고 해서 분리해보겠다.

 

  • UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    @Transactional
    public void signUp(UserDto userDto) {
        if (!userDto.getPassword().equals(userDto.getPasswordCheck())) {
            throw new RuntimeException("비밀번호가 다릅니다.");
        }
        if (userRepository.existsByEmail(userDto.getEmail())) {
            throw new RuntimeException("이메일이 존재합니다.");
        }
        if (userRepository.existsByUsernick(userDto.getUsernick())) {
            throw new RuntimeException("닉네임이 존재합니다.");
        }

        Role role = roleRepository.findByName(userDto.getUsername().equals("admin") ? "ADMIN" : "USER");

        User user = new User();
        user.setRoles(Collections.singleton(role));
        user.setEmail(userDto.getEmail());
        user.setUsernick(userDto.getUsernick());
        user.setUsername(userDto.getUsername());
        user.setPassword(passwordEncoder.encode(userDto.getPassword()));

        userRepository.save(user);
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    @Override
    public void deleteUser(String email) {
        Optional<User> emailOptional = userRepository.findByEmail(email);
        if (emailOptional.isPresent()) {
            userRepository.delete(emailOptional.get());
        } else {
            throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
        }
    }
}
  • signUp 메서드를 보면 username이 admin이면 Role을 ADMIN으로 주고 그게 아니면 USER로 주는 것으로 진행했다.

 

 

5. 컨트롤러

  • Usercontroller.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    // 일반 로그인 회원의 정보 가져오기
    @GetMapping("/home")
    public String index(Model model, Authentication authentication) {

        String username = authentication.getName();
        Optional<User> userOptional = userServiceimpl.findByEmail(username);
        if (userOptional.isPresent()) {
            model.addAttribute("user", userOptional.get());
            return "user/home"; // user/home.html 템플릿으로 이동
        }
        return "redirect:/api/users/login";
    }
}
  • userDto로부터 회원가입에 필요한 정보를 받아서 회원가입을 한다.
  • 여기서 애를 먹었는데, @Postmapping("/login")에서 로그인이 들어오면 그 정보를 가지고 있었는데 저부분 때문에 자꾸 로그인이 안됐다.
  • @Postmapping("/login")  메소드에서 CustomUserDetailsService를 통해 사용자를 로드하고 Authentication을 설정하려고 하지만, 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하는 것이 좋다.

 

 

 

6. UserDetails

  • CustomUserDetails.java
public class CustomUserDetails implements UserDetails {

    private final String email;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(String email, String password, Set<Role> roles) {
        this.email = email;
        this.password = password;
        this.authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • spring Security의 UserDetails 인터페이스를 상속하여 정의한다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.(Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

7. UserDetailsService

  • CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("로그인이 들어왓나?");
        log.info("조회할 이메일: {}", username);
        Optional<User> userOptional = userRepository.findByEmail(username);
        if (userOptional.isEmpty()) {
            log.info("사용자가 없다");
            throw new UsernameNotFoundException("사용자가 존재하지 않습니다: " + username);
        }

        User foundUser = userOptional.get();
        Set<Role> roles = foundUser.getRoles();

        return new CustomUserDetails(
                foundUser.getEmail(),
                foundUser.getPassword(),
                roles
        );
    }
}
  • 사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
  • 만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
  • Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다.
  • CustomUserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
  • Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
  • 즉, loadUserByUsername 메서드는 로그인을 진행할 때 주어진 사용자 이름(email, 로그인 진행 시 아이디)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 한다.
  • jwt 방식을 사용한다면 이 방식은 필요없다.

 

7.  SecurityConfig

  • SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login") // 로그인 페이지의 경로
                        .loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
//                        .usernameParameter("email") // 변경된 필드명
//                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

 

 

 

 

9. 회원가입 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>회원가입 페이지</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #f4f4f4;
        }

        h1 {
            color: #333;
        }

        form {
            background-color: #fff;
            padding: 20px;
            border-radius: 5px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }

        input {
            width: 100%;
            padding: 10px;
            margin-bottom: 10px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }

        button {
            width: 100%;
            padding: 10px;
            background-color: #5cb85c;
            border: none;
            border-radius: 5px;
            color: #fff;
            font-weight: bold;
            cursor: pointer;
        }

        button:hover {
            background-color: #4cae4c;
        }

        .error {
            color: red;
            font-size: 0.9em;
        }
    </style>
</head>
<body>
<form action="/api/users/signup" method="post">
    <h1>회원가입 페이지</h1>
    <hr/>

    <label for="email">Email</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>

    <label for="username">Username</label>
    <input type="text" id="username" name="username" placeholder="Username" required/>

    <label for="usernick">User Nickname</label>
    <input type="text" id="usernick" name="usernick" placeholder="User Nickname" required/>

    <label for="password">Password</label>
    <input type="password" id="password" name="password" placeholder="Password" required/>

    <label for="passwordCheck">Confirm Password</label>
    <input type="password" id="passwordCheck" name="passwordCheck" placeholder="Confirm Password" required/>

    <button type="submit">회원가입</button>
</form>
</body>
</html>

 

 

 

10. 로그인 폼

  • singupform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>
  • 로그인 폼의 action URL은  "/login"으로 설정되어야 한다.
  • SecurityConfig에서 설정한 loginProcessingUrl과 일치해야 한다.

 

여기까지 하면 시큐리티 세션을 이용한 로그인 로그아웃이 가능하다. 


겪은 문제에 대해서 정리...

 

트러블 슈팅 문제

 

문제 1.

기존에 나는 이메일로 로그인을 할것이라서 loginform.html 로그인 폼에서 name="email"과 name="password"가 필드 이름으로 설정하였다.

하지만 Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 로그인 폼의 필드 이름을 수정하거나, SecurityConfig에서 필드명을 커스터마이즈해야 한다. 이거 때문에 애 좀 먹었다...

 

해결 1.

<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>

가장 간단한 해결책은 로그인 폼에서 기본 필드명을 사용하는 것이다.

Spring Security는 기본적으로 username과 password라는 필드명을 기대하기 때문에 저렇게 바꾼다.

 

 

해결 2.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/api/users/login")
                        .loginProcessingUrl("/login")
                        .usernameParameter("email") // 변경된 필드명
                        .passwordParameter("password")
                        .defaultSuccessUrl("/api/users/home")
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .maximumSessions(1)
                        .maxSessionsPreventsLogin(true)
                )
                .userDetailsService(customUserDetailsService)
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

SecurityConfig에서 필드명 커스터마이즈를 하는 것이다. 필드명을 커스터마이즈해야 하는 경우, SecurityConfig에서 설정을 추가로 조정해야 하면 된다.  

.usernameParameter("email") // 변경된 필드명
.passwordParameter("password")
위 두개를 넣으면 해결 된다. 하지만 첫 번째 방법이 편하니깐....ㅎㅎ

 

 

문제 2. @PoastMapping("login") 의 충돌 

@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
    private final UserServiceImpl userServiceimpl;
    private final CustomUserDetailsService customUserDetailsService;

    @GetMapping("/signup")
    public String signup(Model model) {
        model.addAttribute("user", new UserDto());
        return "/user/singupform";
    }

    @PostMapping("/signup")
    public String signup(@ModelAttribute("user") UserDto userDto,
                         RedirectAttributes redirectAttributes) {
        try {
            userServiceimpl.signUp(userDto);
            redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
            return "redirect:/users/login";
        } catch (Exception e) {
            log.error("회원가입 오류 : {} " , e.getMessage());
            redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
            e.printStackTrace();
        }
        return "redirect:/api/users/login";
    }

    @GetMapping("/login")
    public String showLoginForm() {
        return "/user/loginform";
    }

//    @PostMapping("/login")
//    public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
//        try {
//            UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
//            Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
//            SecurityContextHolder.getContext().setAuthentication(authentication);
//        } catch (Exception e) {
//            redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
//        }
//    }

    @GetMapping("/home")
    public String index(Model model) {
        model.addAttribute("user", new UserDto());
        return "/home";
    }

 

해결1.

  • 위에서 spring Security의 UserDetails 인터페이스를 상속하여 정의한 CustomUserDetails 클래스가 있을 것이다.
  • 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
  • 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
  • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 된다!!!!!
  • 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
    (Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.

 

결론.

  • 로그인을 진행하고 @Postmapping("/login")  메소드를 사용해서 로그인을 진행할려고 했는데 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있었다.
  • Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
  • 로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하니깐 오류는 해결되었다.

 

 

 

 

다음으로는 Oauth2 로그인 방식에 대해 쓰겠다.

728x90
728x90

Spring Security 환경설정

시큐리티 적용시 기본적으로 로그인화면이 나온다. 기본 Username/Password = user/시스템 로그에 출력됨

 

Spring Security 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
testImplementation 'org.springframework.security:spring-security-test'

 • 스프링 시큐리티를 사용하기 위한 스타터

• 타임리프에서 스프링 시큐리티를 사용하기 위한 의존성

• 스프링 시큐리티를 테스트 하기 위한 의존성

 

spring:
  security:
    user:
      name: user
      password: 1111

 


SecurityConfig

Spring Security의 환경설정을 구성하기 위한 클래스다!

 

HttpSecurity로 대부분 구현한다고 생각하면 된다. 참고로 현재는 WebSecurityConfigurerAdapter는 사용을 안한다.

  • 먼저 config 패키지에 SecurityConfig라는 시큐리티 설정 파일을 만들어 주고 필요한 @bean들을 추가해 사용할 수 있다. --> 사진을 찾다보니 현재 WebSecurityConfigurerAdapter는 시큐리티3부터 사용을 안하지만 HttpSecurity에 대한 설명이 나와있어서 사용했다. 현재 WebSecurityConfigurerAdapter 를 상속하지 않는다!
  • 이제는 @Bean 으로 SpringSecurityFilterChain 을 구현한다.
  • config 클래스에 @EnableWebSecurity 어노테이션을 달아서 시큐리티 설정을 해준다.
@Configuration
@EnableWebSecurity
public class SecurityConfig{

	// 패스워드 암호화 관련 메소드
  @Bean 
  public PasswordEncoder passwordEncoder(){
      return new BCryptPasswordEncoder();
  }

	// 특정 HTTP 요청에 대한 웹 기반 보안 구성
	// 시큐리티 대부분의 설정을 담당하는 메소드
	@Bean 
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
			http	
				.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.authorizeHttpRequests((authorize) -> authorize
						.requestMatchers("/signup", "/", "/login").permitAll()
						.anyRequest().authenticated()
				)
				// Form 로그인을 활용하는경우 (JWT에는 필요없음)
				.formLogin(form -> form
	            		.loginPage("/loginform") 
	            		.loginProcessingUrl("/login") 
              			.defaultSuccessUrl("/")
              			.permitAll()
        			)
				.logout((logout) -> logout
				.logoutUrl("/logout")
             			.logoutSuccessUrl("/")
				.invalidateHttpSession(true)
				)
				.sessionManagement(sessionManagement -> sessionManagement
             			.maximumSessions(1) 
             			.maxSessionsPreventsLogin(true)
             			.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        			)
			);
		return http.build();
	}

	// 이외에도 등록해서 사용하면 된다..
}

 

코드설명

  • filterChain() : 특정 Http 요청에 대해 웹 기반 보안 구성. 인증/인가 및 로그아웃을 설정한다.
    • .csrf(Cross site Request forgery) : 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
      • REST API를 이용한 개발을 진행 할 예정일 때, Rest Api 환경에서는 Session 기반 인증과 다르기 때문에 서버에 인증 정보를 보관하지 않고, 권한 요청시 필요한 인증정보(OAuth2, Jwt토큰 등)요청을 포함하기 때문에 굳이 불필요한 csrf 보안을 활성화할 필요가 없다.
      • 따라서 csrf는 disable 처리
    • .HttpBasic()
      • HttpBasic() : Http basic Auth 기반으로 로그인 인증창이 뜬다.
    • .authorizeHttpRequests() : 인증, 인가가 필요한 URL 지정
      • anyRequest() : requestMatchers에서 지정된 URL 외의 요청에 대한 설정
      • authenticated() : 해당 URL에 진입하기 위해서는 인증이 필요함
      • requestMatchers("Url").permitAll() : requestMatchers에서 지정된 url은 인증, 인가 없이도 접근 허용
      • Url에 /**/ 와 같이 ** 사용 : ** 위치에 어떤 값이 들어와도 적용 (와일드 카드)
      • hasAuthority() : 해당 URL에 진입하기 위해서 Authorization(인가, 예를 들면 ADMIN만 진입 가능)이 필요함
        • .hasAuthority(UserRole.ADMIN.name()) 와 같이 사용 가능
    • formLogin() : Form Login 방식 적용
      • loginPage() : 로그인 페이지 URL
      • defaultSuccessURL() : 로그인 성공시 이동할 URL
      • failureURL() : 로그인 실패시 이동할 URL
    • logout() : 로그아웃에 대한 정보
      • invalidateHttpSession() : 로그아웃 이후 전체 세션 삭제 여부
    • sessionManagement() : 세션 생성 및 사용여부에 대한 정책 설정
      • SessionCreationPolicy() : 정책을 설정
      • SessionCreationPolicy.Stateless : 4가지 정책 중 하나로, 스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않는다. (JWT와 같이 세션을 사용하지 않는 경우에 사용)

BCryptPasswordEncoder

BCrype 인코딩을 통하여 비밀번호에 대한 암호화를 수행한다.

password를 암호화해줌

  • Spring Security에서 비밀번호를 안전하게 저장할 수 있도록 비밀번호의 단방향 암호화를 지원한다.
    -> PasswordEncoder 인터페이스와 구현체들

  • encode() : 비밀번호를 암호화(단방향)
  • matches() : 암호화된 비밀번호와 암호화되지 않은 비밀번호가 일치하는지 비교
  • upgradeEncoding() : 인코딩된 암호화를 다시 한번 인코딩 할 때 사용 (true일 경우 다시 인코딩, default=false)

 

PasswordEncoder가 제공하는 구현 클래스

  • StandardPasswordEncoder : SHA-256을 이용해 암호를 해시한다. (강도가 약한 해싱 알고리즘이기 때문에 지금은 많이 사용되지 않는다.)
  • BCryptPasswordEncoder : bcrypt 강력 해싱 함수로 암호를 인코딩
  • NoOpPasswordEncoder : 암호를 인코딩하지 않고 일반 텍스트로 유지(테스트 용도로만 사용.)
  • SCryptPasswordEncoder : scrypt 해싱 함수로 암호를 인코딩한다.
@Bean // 패스워드 암호화 관련 메소드
public PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}
  • 현재 사용되는 알고리즘에서 취약성이 발견되어 다른 인코딩 알고리즘으로 변경하고자 할 때 대응하기 좋은 방법은 DelegatingPasswordEncoder을 사용하는 것
@Bean // DelegatingPasswordEncoder: 여러 인코딩 알고리즘을 사용할 수 있게 해주는 기능
	public static PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

 

 


기타 참고용

 

Configure 작성 문법 바뀐 부분

스프링 3.0 이상의 버전부터는 스프링 시큐리티 버전도 바뀌어서 기존의 Configuration과는 다르게 작성해야 한다. WebSecurity, HttpSecurity 모두 큰 변화를 맞이 했는데, 그중 하나가 lambdas 형식의 작성법이다.

  • 람다식을 파라미터로 전달하여 아래와 같이 사용한다.
.formLogin(formLogin -> formLogin
						.loginPage("/login")
						.defaultSuccessUrl("/home"))

 

 

 

HttpSecurity

스프링시큐리티의 각종 설정은 HttpSecurity로 대부분 하게 된다!

Spring Boot 3.1(Spring 6.1) Security Config: 'csrf()' is deprecated and marked for removal

  • 스프링 부트 버전이 올라가면서 작성방식에 차이가 생김

Spring boot 3.0.6, Spring security 6, jwt적용 및 인증, 예외 처리

  • 버전이 올라가면서 동작방식이 달라짐

 

 

HttpSecurity - 리소스(URL) 접근 권한 설정

특정 리소스의 접근 허용 또는 특정 권한을 가진 사용자만 접근을 가능하게 할 수 있다.

http
		
       .authorizeHttpRequests(authorizeRequest -> authorizeRequest
		
        // 해당 경로는 모든 권한을 허용한다.
    	.requestMatchers(HttpMethod.GET, "/login**", "/web-resources/**", "/actuator/**").permitAll()

	// 해당 경로는 어드민 권한이 있어야한다.
    	.requestMatchers(HttpMethod.GET, "/admin/**").hasAnyRole("ADMIN")

	// 해당 경로는 유저 권한이 있어야 한다.
    	.requestMatchers(HttpMethod.GET, "/order/**").hasAnyRole("USER")

	// 나머지는 모두 권한이 필요하다.
    	.anyRequest().authenticated()
  • requestMatchers
    • 특정 리소스에 대해서 권한을 설정한다.
  • permitAll
    • 리소스의 접근을 인증절차 없이 허용한다.
  • authenticated
    • 리소스의 접근을 인증절차를 통해 허용한다.
  • hasAnyRole
    • 해당 권한을 가진 사용자만 접근을 허용한다.
  • anyRequest
    • 모든 리소스를 의미하며, anyMatcher로 설정하지 않은 리소스를 말한다. 

 

HttpSecurity - 로그인처리 설정

로그인 FORM 페이지를 이용하여 로그인하는 방식을 사용하려고 할때 여러가지 설정을 할 수 있다.

// Form 로그인을 활용하는경우 (JWT에는 필요없음)
// .formLogin(Customizer.withDefaults()); // Security가 제공하는 로그인 방식 사용
.formLogin(formLogin -> formLogin
        .loginPage("/login")
        .loginProcessingUrl("/loginProc")
        .usernameParameter("userId")
        .passwordParameter("userPw")
        .permitAll())

 


JwtAuthenticationFilter 사용

HttpSecurity - 커스텀 필드 등록 ⭐

커스텀 필터를 생성해서 등록할 수 있다!

.addFilterBefore(jwtAuthenticationFilter, 
	UsernamePasswordAuthenticationFilter.class)
    // UsernamePasswordAuthenticationFilter가 기존 시큐리티 세션 방식의 로그인 필터이기 때문에
    // UsernamePasswordAuthenticationFilter 앞에 커스텀한 필터 체인을 넣어준다.
  • addFilterBefore
    • 지정된 필터 앞에 커스텀 필터를 추가한다.
  • addFilterAfter
    • 지정된 필터 뒤에 커스텀 필터를 추가한다.
  • addFilterAt
    • 지정된 필터의 순서에 커스텀 필터가 추가된다.

 

 

JwtAuthenticationFilter

  • JwtAuthenticationFilter.java
    • jwt 방식으로 로그인을 진행할 것이기 때문에 커스텀한 필터이다.
@Order(0)
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = parseBearerToken(request);

        // 토큰값이 유요하다면 검증을 시작한다.
        if (token != null && tokenProvider.validToken(token)) {
            // 토큰 검증
            Authentication authentication = tokenProvider.getAuthentication(token);

            // SecurityContextHolder => 인증정보를 담는다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("Security Context에 {} 인증 정보를 저장했다", authentication.getPrincipal());
        } else {
            log.info("유효한 JWT 토큰이 없습니다, uri: {}", request.getRequestURI());
        }

        filterChain.doFilter(request, response);
    }

    /**
     * Authorization Bearer 제거(공백포함 7글자)
     * @param request 요청 request
     * @return token (없는경우 null)
     */
    private String parseBearerToken(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
                .filter(token -> token.length() >= 7 && token.substring(0, 7).equalsIgnoreCase("Bearer "))
                .map(token -> token.substring(7))
                .orElse(null);
    }
}

 


JwtAuthenticationFilter에 대해서는 이전 블로그에 자세하게 적어놓았다.
Spring Security + JWT (RefreshToken, AccessToken)를 사용한 로그인, 로그아웃 구현 - 4편 :: 미정 (tistory.com)

 

Spring Security + JWT (RefreshToken, AccessToken)를 사용한 로그인, 로그아웃 구현 - 4편

개요* 1~3편 정리1편에서 기본적인 세팅은 끝이 났다.2편에서는 SecurityConfig에 대해서 보안 설정을 했다.3편에서는 RefreshToken과 BlackListToken에 대해서 엔티티와 레포지토리, 서비스를 작성했다. 4편

eesko.tistory.com

 

jwt에 대해서 알고싶다면

[멋쟁이사자처럼 백엔드 TIL] Spring Security : JWT(JSON Web Token) :: 미정 (tistory.com)

728x90

+ Recent posts