개발

[Spring Boot] Security + jwt로 인증, 인가 구현하기

changha. 2023. 7. 6. 17:46

 

스프링 시큐리티에서 JWT 토큰 인증 방식을 사용하기 위해서는 다음과 같은 과정을 거칩니다:

  1. 사용자 로그인: 클라이언트에서 사용자의 로그인 정보(예: 아이디와 비밀번호)를 서버에 전송합니다.
  2. 인증: 서버는 전달받은 로그인 정보를 확인하고, 사용자가 유효한지 확인합니다. 이 단계에서는 사용자의 아이디와 비밀번호를 검증하는 등의 인증 과정이 이루어집니다.
  3. 토큰 생성: 인증이 성공적으로 이루어지면, 서버는 사용자를 대표하는 JWT 토큰을 생성합니다. 이 토큰에는 사용자의 식별 정보와 필요한 추가 정보가 포함됩니다.
  4. 토큰 전달: 서버는 생성된 JWT 토큰을 클라이언트에게 전달합니다. 일반적으로는 HTTP 응답의 헤더나 JSON 응답의 일부로 토큰이 포함됩니다.
  5. 토큰 저장: 클라이언트는 받은 토큰을 안전한 곳에 저장합니다. 일반적으로는 웹 브라우저의 로컬 스토리지나 세션 스토리지에 저장하거나, 모바일 앱에서는 안전한 저장소에 저장합니다.
  6. 토큰 인증: 클라이언트가 서버에 요청을 보낼 때마다, 토큰을 함께 전달합니다. 서버는 토큰의 유효성을 확인하고, 클라이언트의 요청을 처리합니다. 이 과정에서 서버는 토큰의 서명을 확인하여 변조

여기서는 간단하게 로그인 구현하고 token을 발급하는 것에 초점을 맞춰보겠습니다

완성된 패키지 구조 입니다.

초기 설정

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.13'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
    maven {
        url 'https://repo.clojars.org'
        name 'Clojars'
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'com.h2database:h2'



    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'


}

tasks.named('test') {
    useJUnitPlatform()
}

스프링 버전이 3.xx일 때 config 설정할 때 deprecated된게 많아 편의상 2.7.13으로 설정 했습니다

application.yml

jwt:
  secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK

secret key는 terminal에서

이런식으로 간단히 만들 수 있다.

로그인

MemberController

@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

   @PostMapping("login")
   public ResponseEntity<String> login(@RequestBody LoginDto dto){
       return ResponseEntity.ok().body(memberService.login(dto.getUsername()));
   }
}

LoginDto에서 편의상 pw는 제외하고 username을 가져와 MemberService의 login 메서드에 넘겨주는 역할을 합니다.

반환값으로 String을 받습니다.

MemberService

@Service
public class MemberService {

    @Value("${jwt.secret}")
    private String secretKey;

    private Long expiredMs = 1000 * 60 * 60l;

    public String login(String username){
        return JwtUtil.createJwt(username, secretKey, expiredMs);
    }
}

@Value를 통해 application.yml에 있는 jwt.secret을 가져올 수 있다.

expiredMs는 token 만료시간을 설정하는 건데 1시간으로 설정 되어있다.

login메서드에는 username,secretKey, expiredMs를 JwtUtil 의 createJwt메서드에 보내는 역할을 합니다.

 

LoginDto

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class LoginDto {
    private String username;
}

 

JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final MemberService memberService;

    @Value("${jwt.secret}")
    private final String secretKey;

    // 필터 실제 로직 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        logger.info("authorization : " + authorization);

        //token 안
        if (authorization == null || !authorization.startsWith("Bearer ")){
            logger.error("authorization을 잘못 보냈습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        //token 꺼내기
        String token = authorization.split(" ")[1];
        logger.info("token : " + token);

        //token Expired되었는지
        if(JwtUtil.isExpired(token, secretKey)){
            logger.error("Token이 만료 되었습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        //username token에서 꺼내기
        String userName = JwtUtil.getUserName(token, secretKey);
        logger.info("username: " + userName);

        //인증된 사용자를 나타내는 토큰 객체를 생성하고, 권한 정보를 설정
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userName, null, List.of(new SimpleGrantedAuthority("USER")));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

JwtUtil

public class JwtUtil {

    public static String getUserName(String token, String secretKey){
        String res = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
                .getBody().get("userName", String.class);
        System.out.println("res : " + res);
        return res;
    }

    public static boolean isExpired(String token, String secretKey){
        return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token)
                .getBody().getExpiration().before(new Date());
    }

    public static String createJwt(String username, String secretKey, Long expiredMs){
        Claims claims = Jwts.claims();
        claims.put("userName", username);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

    }
}

createJwt() : jwt생성로직
setClaims(): claim부분에 username을 담음 

setIssuedAt(): 토큰 생성시간

setExpiration(): 토큰 만료시간

signWith(): 토큰의 서명 알고리즘과, SecretKey를 이용하여 서명합니다.

 

compact(): 설정된 정보를 바탕으로 jwt를 생성합니다. 생성된 토큰을 문자열 형태로 반환합니다. 

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig {

    private final MemberService memberService;

    @Value("${jwt.secret}")
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers("/api/v1/members/login").permitAll()
                .antMatchers(HttpMethod.POST, "/api/v1/**").authenticated()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .build();
    }



}
  • .httpBasic().disable(): HTTP 기본 인증을 비활성화합니다. 기본 인증은 사용자명과 비밀번호를 평문으로 전송하기 때문에 보안에 취약합니다.
  • .csrf().disable(): CSRF(Cross-Site Request Forgery) 공격 방지 기능을 비활성화합니다. 이 설정은 RESTful API 서비스에서 CSRF 공격을 대응하기 위해 사용됩니다.
  • .cors().and(): Cross-Origin Resource Sharing(CORS) 설정을 활성화합니다. CORS는 도메인이나 포트가 다른 리소스에 대한 접근을 제어하는 기능입니다.
  • .authorizeRequests(): 요청에 대한 인가 규칙을 설정합니다.
  • .antMatchers("/api/v1/members/login").permitAll(): "/api/v1/members/login" 경로에 대한 요청은 모든 사용자에게 허용합니다. 즉, 로그인 API는 인증 없이 접근 가능합니다.
  • .antMatchers(HttpMethod.POST, "/api/v1/**").authenticated(): "/api/v1/"로 시작하는 POST 메서드의 요청은 인증된 사용자만 접근할 수 있습니다. 즉, 인증된 사용자만이 POST 요청을 보낼 수 있습니다.
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): 세션 관리를 설정하고, 세션 생성 정책을 STATELESS로 지정합니다. STATELESS 정책은 서버가 세션을 유지하지 않고, 각 요청마다 사용자를 인증합니다. JWT 토큰 기반의 인증을 사용할 때 주로 이 정책을 사용합니다.
  • .addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class): JwtFilter를 UsernamePasswordAuthenticationFilter 이전에 추가합니다. 앞서 설명한 것처럼 JwtFilter는 토큰의 검증과 사용자 인증을 처리하는 역할을 수행합니다.
  • .build(): 설정을 적용하여 HttpSecurity 객체를 빌드하고 반환합니다.

 

ReviewController

@RestController
@RequestMapping("/api/v1/reviews")
public class ReviewController {

    @PostMapping
    public ResponseEntity<String> writeReview(Authentication authentication){
        return ResponseEntity.ok().body(authentication.getName() + "님의 리뷰 등록이 완료 되었습니다.");
    }
}

token을 통해 인증이되면 리뷰를 등록할 수 있도록 간단히 작성 되어있습니다. 

 

POST - http://localhost:8080/api/v1/members/login

받은 토큰으로 Authorization을 "Bearer " + token 형태로 입력하고 전송하면 

결과를 위와 같이 받을  수 있습니다.

 

https://github.com/Changha-dev/jwt-security-v1

 

GitHub - Changha-dev/jwt-security-v1

Contribute to Changha-dev/jwt-security-v1 development by creating an account on GitHub.

github.com

전체 소스코드는 깃허브에 올려놨습니다! 

 

참고 강의 

https://www.youtube.com/watch?v=YEB0Ln6Lcyk 


token을 어떤식으로 발급하고 사용하는지 알았으니 

이제 로그인과 회원가입 로직을 제대로 추가해서 더 완성도있게 만들어봐야겠다!