코드는 ahnjehoon/spring-boot3-tutorial (github.com) 에 올라가 있습니다

 

JPA와 연동해 로그인을 진행해보겠습니다

 


개발환경

  • JDK 17
  • intellij 2023.3.6 (Community Edition)
  • Spring Boot 3.2.4
  • spring-boot-starter-security 3.2.4
  • spring-boot-starter-oauth2-resource-server 3.2.4

간단한 예외 처리

spring에서 예외가 발생하면 /error 엔드포인트로 리다이렉트됩니다

spring security 설정시 spring security filter가 동작하기 때문에,

권한이 없는 사용자의 경우 401 Unauthorized 응답만 받게 됩니다

이를 해결하기 위한 방법은 두 가지입니다:

  1. Spring Security 설정에서 /error 엔드포인트에 대한 접근 권한을 permitAll()로 열어줍니다.
  2. @RestControllerAdvice를 사용하여 직접 예외 처리 핸들러를 작성하고, 에러 응답을 반환합니다.

1의 경우 단순히 에러코드만 반환하기때문에 로그를 보지 않고는 확인이 어렵습니다.

2의 경우 에러 메세지를 반환하여 예외 메세지 처리를 해두었다면 어디서 문제가 발생했는지 확인이 가능합니다.

2방법으로 간단하게 처리하고 넘어가겠습니다.

config/ExceptionConfig.java

@RestControllerAdvice
public class ExceptionConfig {
    @ExceptionHandler(Exception.class)
    protected final ResponseEntity<String> handleAllException(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getMessage());
    }
}

비밀번호 암호화 BCryptPasswordEncoder

비밀번호를 저장할때는 평문으로 저장하면 안됩니다

spring security에서는 평문을 암호화 해주는 기능을 제공합니다

기본값으로 blowfish 알고리즘을 이용한 bcrypt 암호화 방식이 설정되어 있습니다

SecurityConfig에 passwordEncoder를 추가할때 설명이 들어갔어야 했는데 누락됐네요..
간단하게 정리해보겠습니다

  1. 랜덤 salt 생성
  2. salt + 평문 조합 (EksBlowfishKey)
  3. 조합된 값으로 암호화 함수 반복 실행 (ExpensiveKey)
  4. 결과도출 = 버전 + 반복 횟수 + 반복 실행 결과 값 + 암호화에 사용한 사용한 salt값

계산 비용이 높기때문에 공격자가 무차별대입공격시 계산 자원이 많이 필요하게 만드는 방식입니다
서버도 마찬가지로 암호를 검증할때 많은 자원을 소모하는 단점이 있습니다


사용자 테이블

entity/User.java

@Entity
@Getter
@Setter
@Table(name = "users")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    private String id;
    private String password;
    private String name;
    private String email;
}

 

repository/UserRepository.java

public interface UserRepository extends JpaRepository<User, String> {
    User findByIdAndPassword(String id, String password);
}

 

service/UserService.java

bcrypt는 복호화가 불가능하고 salt값을 매번 새롭게 만들기 때문에 검색조건에 추가할 수 없습니다

사용자 정보를 먼저 가져와서 암호화된 값에 저장된 salt값으로 비교해야합니다

 

'사용자 정보를 찾을 수 없다'는 메세지와 '비밀번호가 일치하지 않는다'는 메세지는 실무에서는 사용하지 마세요

실무에서는 '일치하는 사용자 정보를 찾을 수 없다'고만 알려주세요

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User select(LoginRequest param) {
        var user = userRepository.findById(param.getId())
                .orElseThrow(() -> new RuntimeException("사용자 정보를 찾을 수 없습니다"));

        if (!passwordEncoder.matches(param.getPassword(), user.getPassword())) {
            throw new RuntimeException("패스워드가 일치하지 않습니다");
        }
        return user;
    }
}

 

dto/LoginRequest.java

@Getter
@Validated
public class LoginRequest {
    @NotEmpty
    private String id;
    @NotEmpty
    private String password;
}

 

controller/AuthController.java

jwt에서 claim 데이터를 추출하기 위한 방식 2가지로

/api/auth/token-info 에서는 jwt를 decode 해서 추출하고

/api/auth/token-info2에서는 SecurityContextHolder에서 추출해봤습니다

@RestController
@RequestMapping("auth")
@RequiredArgsConstructor
public class AuthController {
    private final JwtEncoder jwtEncoder;
    private final JwtDecoder jwtDecoder;
    private final UserService userService;

    @PostMapping("login")
    public String login(@RequestBody LoginRequest request) {
        final var user = userService.select(request);
        return createToken(user);
    }

    @GetMapping("token-info")
    public String decode(@RequestParam("token") String token) {
        final var jwt = jwtDecoder.decode(token);
        final var subject = jwt.getSubject();
        final var name = jwt.getClaim("name");
        final var email = jwt.getClaim("email");
        return String.format("subject: %s, name: %s, email: %s", subject, name, email);
    }

    @GetMapping("token-info2")
    public String decode() {
        final var authentication = SecurityContextHolder.getContext().getAuthentication();
        final var token = ((JwtAuthenticationToken) authentication).getToken();
        return String.format("subject: %s, name: %s, email: %s",
                token.getSubject(),
                token.getClaim("name"),
                token.getClaim("email")
        );
    }

    private String createToken(User user) {
        final var jwtClaimsSet = JwtClaimsSet.builder()
                .issuer("middle-developer.tistory.com")
                .issuedAt(Instant.now())
                .subject(user.getId())
                .expiresAt(Instant.now().plusSeconds(60 * 60 * 24))
                .claim("name", StringUtils.isEmpty(user.getName()) ? "" : user.getName())
                .claim("email", StringUtils.isEmpty(user.getEmail()) ? "" : user.getEmail())
                .build();

        return jwtEncoder.
                encode(JwtEncoderParameters.from(jwtClaimsSet))
                .getTokenValue();
    }
}

데이터 초기화

테스트 코드를 작성하거나 사용자 정보가 없어서 로그인이 불가능한 경우처럼 초기데이터가 필요할때가 있습니다

jpa.hibernate.ddl-auto=create일때 sql.init.data-locations에서 sql 파일을 읽어 처리하는 방법과

서버가 시작될 때 실행되는 특별한 인터페이스인 CommandLineRunner를 구현하는 방법이 있습니다

CommandLineRunner를 구현하는 방법으로 초기화를 진행해보겠습니다

config/DataInitializer.java

@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public void run(String... args) throws Exception {
        if (userRepository.count() > 0) {
            return;
        }
        initUser();
    }

    private void initUser() {
        var users = new ArrayList<User>();

        users.add(User.builder()
                .id("admin")
                .password(passwordEncoder.encode("P@ssw0rd"))
                .name("관리자")
                .build());

        users.add(User.builder()
                .id("user")
                .password(passwordEncoder.encode("P@ssw0rd"))
                .name("사용자")
                .build());

        userRepository.saveAll(users);
    }
}

 


서버 재시작할때마다 인증서가 초기화 되니 401에러가 발생된다면

swagger에서 Authorize 값을 넣어주지 않았나 확인해보세요 ^^

 

다음은 마지막으로 배포를 진행해보겠습니다

이전 글에서는 Spring Security를 간단하게 사용해보았습니다.

하지만 매번 csrf 토큰을 확인해줘야 하는 문제와 사용자의 패스워드가 그대로 노출되는 문제가 있었습니다.

개발 환경

  • Spring Boot 3.2.4
  • spring-boot-starter-security
  • spring-boot-starter-oauth2-resource-server

config/security/SecurityConfig.java

...
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http

            // CORS CSRF 미사용처리
            .cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)

            // 인증 활성화
            .authorizeHttpRequests(auth -> auth
                    .anyRequest().authenticated())

            // Form Login 활성화
            .formLogin(Customizer.withDefaults())
            
            // 동일도메인 iframe 접근 활성화
            .headers(header -> header
                    .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
            )

            .build();
}
...

 

CORS와 CSRF를 비활성화 처리를 했습니다

FilterChain을 새롭게 구성하면 인증과 Form Login 활성화가 필요합니다.

동일 도메인 iframe 접근 활성화 주석된 부분을 포함하지 않고 h2-console에 접속하면

로그인 페이지는 보이겠지만 로그인을 성공하면 아래 페이지처럼 보일것입니다

 

위 처럼 뜨는 이유는 h2-console이 iframe을 사용하는데

spring security는 Clickjacking 공격을 막기위해 기본적으로 비활성화되어 있다고 합니다

https://stackoverflow.com/a/28649205

 

How to disable 'X-Frame-Options' response header in Spring Security?

I have CKeditor on my JSP and whenever I upload something, the following error pops out: Refused to display 'http://localhost:8080/xxx/xxx/upload-image?CKEditor=text&CKEditorFuncNum=1&lang...

stackoverflow.com

 

위 까지만 설정을 하고 브라우저를 통해 테스트를 하면 정상적으로 동작하는 것을 확인할 수 있습니다.

 

POSTMAN으로 테스트 하실때는

먼저 아래 사진처럼 POST 요청으로 로그인을 진행해주세요 

 

그리고 나서 다른 API를 테스트 하시면 정상적으로 동작할겁니다.

POSTMAN도 내부적으로 쿠키를 관리하고 있어서

로그인 요청시 Header 에 JSESSIONID 정보를 담아서 전송하고

로그인이 성공하면 이후의 API 요청은 정상적으로 처리됩니다

주의: 부정확함. securityFilter 빈을 별도로 등록하면 기본값으로 세션인증방식으로 처리되는 것 같습니다.

 

다음글에서는 JWT 를 이용한 인증을 해보겠습니다

Spring Security

보안을 쉽게 구현하고 관리할 수 있도록 도와주는 프레임워크 입니다

주요 기능으로는 인증(Authentication), 권한 부여(Authorization),  CORS와 CSRF등과 같은 공격으로 부터

어플리케이션을 보호합니다.

위에 대한 설명을 요약하자면 대략 다음과 같습니다

  • Authentication(인증)
    • 시스템에 접근하려는 사용자의 신원을 확인하는 프로세스
    • 일반적으로 ID/PW나 토큰 기반으로 이루어짐
    • 인증 성공시 시스템에 접근할 수 있는 권한을 부여받음
  •  Authorization(인가)
    • 인증된 사용자에게 특정 리소스나 작업에 대한 접근 권한을 부여하는 프로세스
    • 예를들어 관리자는 전체 접근권한, 일반 사용자는 제한된 접근권한을 부여할 수 있음
  • CORS(Cross Origin Resource Sharing)
    • 다른 출처(도메인, 프로토콜, 포트)의 리소스에 접근을 허용하는 매커니즘
    • 일반적으로 브라우저는 동일 출처 정책(Same Origin Policy)에 의해 다른 출처의 리소스 접근을 제한
  • CSRF(Cross Site Request Forgery)
    • 사용자가 의도하지 않은 요청을 웹 애플리케이션에 전송하도록 속이는 공격
    • EX) 인증된사용자 → 악의적 링크 클릭 → 공격자가 설정한 기능 수행
    • 방어 방법
      1. CSRF 토큰 사용 모든 요청마다 사용자의 세션과 연결된 고유한 토큰을 생성하여 유효성 검증
      2. SameSite 쿠키 속성 사용 다른 도메인에서의 요청 제한
      3. Referer 검증 요청이 발생한 페이지의 Referer 헤더를 확인하여 요청 출처 확인
      4. 사용자 입력 검증

추가로 클라이언트가 HTTP 요청을 하면 REST 컨트롤러에 도달하기전에 다양한 필터를 거치게 됩니다

다음은 spring security 적용시 어떻게 변하는지 간단하게 테스트 해보겠습니다

개발 환경

  • Spring Boot 3.2.4
  • spring-boot-starter-security
  • spring-boot-starter-oauth2-resource-server

build.gradle

...
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'

    // runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
    // runtimeOnly 'com.mysql:mysql-connector-j'

    // QueryDSL
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'

    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}
...

 

config/security/SecurityConfig.java

@Configuration
public class SecurityConfig {

    @Bean
    UserDetailsService userDetailsService() {
        var userDetails = User.builder()
                .username("admin")
                .password("{noop}P@ssw0rd")
                .build();
        return new InMemoryUserDetailsManager(userDetails);
    }
}

 

 

위 코드에서 {noop}는 패스워드를 암호화 하지 않고 사용하겠다는 뜻입니다

 

패스워드를 암호화 할 경우 다음 코드처럼 사용하시면 됩니다

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

    @Bean
    UserDetailsService userDetailsService() {
        var userDetails = User.builder()
                .username("admin")
                .password("P@ssw0rd")
                .passwordEncoder(str -> passwordEncoder().encode(str))
                .build();
        return new InMemoryUserDetailsManager(userDetails);
    }
}

 

테스트

이제 프로젝트를 실행시키고 http://localhost 로 접속하게 되면

 

로그인하라는 창이 나옵니다

여기에 admin / P@ssw0rd 를 입력하면 원래 가려고 했던 주소로 갈 수 있습니다.

API 테스트시 GET 요청을 제외한 문제 발생

 

GET 요청은 제대로 동작하는데 POST, PUT, DELETE 요청은 아래 처럼 403 에러가 발생합니다

403 문제가 발생하는 POST 요청

Postman을 사용한 403 CSRF 해결방법

원인은 CSRF 때문에 그렇습니다

이걸 해결하려면 postman 같은 api를 테스트 할 수 있는 도구를 사용해야합니다

아래 링크에서 다운로드가 가능합니다

Download Postman | Get Started for Free

 

Download Postman | Get Started for Free

Try Postman for free! Join 30 million developers who rely on Postman, the collaboration platform for API development. Create better APIs—faster.

www.postman.com

 

실행하면 다음과 같은 화면이 나올겁니다

저는 로그인을 하지 않고 사용할것이기 때문에 lightweight API client 를 클릭했습니다

 

그리고 http://localhost/login URL로 GET 요청을 전송하면 _csrf 값이 input 태그안에 포함되어 있을겁니다

_csrf의 value 값을 복사해줍니다

 

 

 

그다음 새로운 요청을 만들어서 Authorization, X-CSRF-TOKEN, Body 값을 추가해줍니다

 

 

Authorization 탭에서 Basic Auth를 선택하고 username과 password를 입력해주세요

 

 

Headers 탭에서 X-CSRF-TOKEN 키에 대한 값으로 처음에 복사했던 _csrf 값을 넣어주세요

 

 

Body 탭에서 위 그림처럼 넣어주면 됩니다

저는 postman에서 지원하는 random 값을 넣어주었습니다

{
  "code""{{$randomWord}}",
  "name""{{$randomWord}}",
  "type""{{$randomWord}}"
}
 
그리고 POST 요청을 전송하게 되면 드디어 정상적으로 요청이 수행되는 것을 확인 할 수 있습니다
 
 

하지만...

하지만 문제가 있습니다
위 방식으로 로그인을 한다면 CSRF 토큰을 매번 확인해줘야 하고
API 요청시 Authorization Header 값을 확인해보면 
 

 

YWRtaW46UEBzc3cwcmQ=값을 확인할 수 있는데 이 값은 base64 인코딩 값이여서 보안에 취약합니다.

아래 사이트에서 Decode가 가능합니다

Base64 Decode and Encode - Online

decode 하여 원본 값 추출이 가능합니다

 

다음 글에서는 이 문제들을 해결해보겠습니다

 

GitHub - ahnjehoon/spring-boot3-tutorial

 

참조

Hello World

  • 누군가 저와 같은 삽질을 반복할 것 같아 작성합니다
  • DBMS는 H2를 사용하다가 MySQL 및 MsSQL 테스트 예정입니다
  • JPA와 QueryDsl을 사용할 예정입니다
  • Spring Security와 JWT를 통해 인증할 예정입니다

환경

  • Open JDK 17
  • Spring Boot 3.2.4
  • Intellij Community 2023.3.6

개발환경 설정

  • JAVA 환경설정은 다른 분들이 올려놓으신 자료가 많기 때문에 넘어갑니다
  • 다음 사이트로 이동해서 스프링 초기 세팅을 구성합니다
    Spring Initializr
  • 제가 설정한 항목은 다음과 같습니다
    • Spring Web
    • Spring Data JPA
    • H2
    • Lombok

 

 

서버 띄워보기

  • src > main > java > [프로젝트 주소] > [Application.java]를 더블클릭합니다
  • 윈도우 기준 SHIFT + F9를 눌러 서버를 실행합니다

 

  • 로그에 Tomcat started on port 8080이라는 명령어가 아래에서 두 번째 줄에 보이면
    브라우저를 열고 http://localhost:8080 으로 이동해 봅니다

  • 위와 같은 페이지를 만난다면 서버가 정상적으로 띄워진 겁니다

Swagger  적용

build.gradle
...
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// Swagger
	implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
}
...

 

  •  루트경로로 접근 시 바로 Swagger 창이 열리도록 변경합니다

application.properties

spring.application.name=tutorial

springdoc.swagger-ui.path=/

 

  • 서버를 재시작하면 다음 화면이 정상적으로 출력되어야 합니다

 

Hello World API 생성

  • 이제 HelloWorld API를 생성해 보겠습니다
com/jehoon/tutorial/controller/HelloController.java
package com.jehoon.tutorial.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    @GetMapping("/hello-world")
    public String get() {
        return "Hello World";
    }
}

 

  • 서버를 재시작하고 브라우저를 확인하면 hello-controller 가 생긴 것을 확인할 수 있습니다.
  • /hello-world API > Try it out > Execute
    다음처럼 Hello World가 정상출력됩니다

'JAVA > SPRING' 카테고리의 다른 글

Spring Boot 3 Tutorial - 5 Security(2)  (0) 2024.04.16
Spring Boot 3 Tutorial - 5 Security(1)  (2) 2024.04.12
Spring Boot 3 Tutorial - 4 QueryDsl  (0) 2024.04.08
Spring Boot 3 Tutorial - 3 DBMS switch  (0) 2024.04.04
Spring Boot 3 Tutorial - 2 CRUD  (0) 2024.04.04

+ Recent posts