본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #005] 로그인 구현 (Feat.JWT 기반 인증)

by 뚜루리 2025. 4. 22.
728x90
320x100

🎯 오늘의 목표

  • 로그인 구현

⚙️ 진행한 작업

  1. 로그인 구현
    1. JWT 기반 인증 활용
    2. 리프레시 토큰 도입-

🛠️ 개발내용

  • 나는 이 프로젝트에서 세션 기반 인증이 아닌 토큰 기반인증 즉, JWT를 도입하기로 함
    • why? 이 프로젝트에서는 웹/앱을 둘 다 고려중이 기 때문에 JWT방식이 더 유연하다고 판단.

 

📂 세션 기반 인증과 토큰 기반 인증이 뭔가요?

 

서버 기반 인증 VS 토큰 기반 인증 (Session, JWT 등)

인증 방식엔 크게 서버 기반 인증과 토큰 기반 인증으로 나뉜다. [🔐 인증 방식 분류]1. 서버 기반 인증 (Server-Side Authentication)서버가 사용자 인증 상태를 기억하고 있음 (Stateful)대표 방식: 세션

ddururiiiiiii.tistory.com

 


 

📌 의존성 추가 (build.gradle)

  • JWT 토큰을 생성하고 파싱하기 위해 jjwt 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JSON 처리용

 


📌 JWT 유틸 클래스 생성 (JwtUtil.java)

  • JWT 생성, 검증, 이메일 추출 등 핵심 기능 담당
  • SECRET_KEY, 만료시간, 서명 알고리즘 설정 포함
package ddururi.bookbookclub.global.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {

    private static final long EXPIRATION_MS = 1000 * 60 * 60 * 24; // 1일
    private static final String SECRET_KEY = "비밀번호는비밀이에용"; // 32바이트 이상

    private Key key;

    @PostConstruct
    public void init() {
        this.key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
    }

    // JWT 생성
    public String createToken(String email) {
        return Jwts.builder()
                .setSubject(email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    // JWT 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // JWT에서 이메일 추출
    public String getEmailFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

 

  • createToken(String email) → 로그인 성공 시 토큰 생성
  • validateToken(String token) → 요청마다 토큰 검증
  • getEmailFromToken(String token) → 사용자 식별 정보 추출

 


📌 사용자 정보 포장 클래스 (CustomUserDetails.java)

  • 스프링 시큐리티가 이해할 수 있도록 User 객체를 래핑하는 클래스
  • JWT에서 사용자 정보 꺼내 인증 시에 이 객체로 감싸서 넣어줌
package ddururi.bookbookclub.global.security;

import ddururi.bookbookclub.domain.user.entity.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections;

@Getter
public class CustomUserDetails implements UserDetails {

    private final User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    // 권한 설정
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
    }


    @Override
    public String getPassword() {
        return user.getPassword(); // 로그인용일 때만 사용됨
    }

    @Override
    public String getUsername() {
        return user.getEmail(); // unique key
    }

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

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

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

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

    public Long getId() {
        return user.getId();
    }

    public String getNickname() {
        return user.getNickname();
    }

    public User getUser() {
        return user;
    }
}

 

 

  • UserDetails를 구현
  • getUsername()은 이메일, getAuthorities()는 역할 반환

 


 

📌 인증 필터 구현 (JwtAuthenticationFilter.java)

  • 모든 요청 전에 작동하는 필터
  • Authorization 헤더에서 토큰 꺼내고 → 검증하고 → SecurityContext에 인증 정보 저장
package ddururi.bookbookclub.global.jwt;

import ddururi.bookbookclub.domain.user.entity.User;
import ddururi.bookbookclub.domain.user.repository.UserRepository;
import ddururi.bookbookclub.global.security.CustomUserDetails;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

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

        // Authorization 헤더에서 토큰 추출
        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7); // "Bearer " 이후

            if (jwtUtil.validateToken(token)) {
                String email = jwtUtil.getEmailFromToken(token);

                User user = userRepository.findByEmail(email)
                        .orElse(null); // 탈퇴하거나 삭제된 유저면 인증 안 함

                if (user != null) {
                    CustomUserDetails customUserDetails = new CustomUserDetails(user);

                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }

        filterChain.doFilter(request, response);
    }
}

 

 

  • 헤더에 Bearer <token> 형식이 있으면
  • jwtUtil로 검증 → UserRepository로 사용자 조회
  • 인증 성공 시 SecurityContextHolder에 등록

 


📌 시큐리티 설정 (SecurityConfig.java)

  • 시큐리티 전체 설정 담당
  • CSRF 비활성화, 세션 사용 안 함, 인증 필터 등록, 경로별 권한 설정
package ddururi.bookbookclub.global.config;

import ddururi.bookbookclub.global.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/api/users/login",
                                "/api/users",
                                "/api/users/check-email",
                                "/api/users/check-nickname"
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

}
  • permitAll(): 회원가입, 로그인 API는 인증 없이 허용
  • anyRequest().authenticated(): 그 외는 토큰 필요

 

✅ CSRF를 disable() 해야 하는 이유

  • 스프링 시큐리티는 기본적으로 CSRF 방어를 켜둠
  • 하지만 우리는 JWT를 헤더에 직접 넣어 요청하기 때문에 CSRF 공격에 해당되지 않음
  • 그래서 설정에서 반드시 명시적으로 꺼줘야 함

 


 

📌 컨트롤러 구현 (UserController.java)

1. 인증 사용자 정보 조회 (@AuthenticationPrincipal 사용)

  • 로그인 성공 시 jwtUtil.createToken(email) 호출 → 클라이언트에게 토큰 전달
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody @Valid UserLoginRequest request) {
    UserResponse user = userService.login(request);
    String token = jwtUtil.createToken(user.getEmail());
    LoginResponse response = new LoginResponse(token, user);
    return ResponseEntity.ok(ApiResponse.success(response));
}

 

 

📌 로그인에 빌요한 DTO 생성 (LoginResponse.java)

package ddururi.bookbookclub.domain.user.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class LoginResponse {
    private String token;
    private UserResponse user;
}

 

 

[Postman 을 통한 테스트]

 


POST http://localhost:8080/api/users/login
{
  "email": "test@example.com",
  "password": "12345678"
}

 

1. 인증 사용자 정보 조회 (@AuthenticationPrincipal 사용)

@GetMapping("/me")
public ResponseEntity<ApiResponse<UserResponse>> getMyInfo(
        @AuthenticationPrincipal CustomUserDetails userDetails
) {
    return ResponseEntity.ok(ApiResponse.success(UserResponse.from(userDetails.getUser())));
}

 

[Postman 을 통한 테스트]


GET http://localhost:8080/api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
{
  "success": true,
  "data": {
    "id": 1,
    "email": "test@example.com",
    "nickname": "북덕이",
    "role": "USER"
  },
  "message": null
}
728x90
320x100