코드는 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 값을 넣어주지 않았나 확인해보세요 ^^

 

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

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

이번에는 JWT를 사용하여 인증 처리를 해보겠습니다.

 

JWT 토큰을 발급할때

io.jsonwebtoken:jjwt-[api, gson, impl] 를 사용하거나

com.auth0:java-jwt 같은 라이브러리를 사용해서 구현해도 되지만

JWT 유효성 검증 필터를 별도로 구현해서 Security Filter에 추가해줘야 하는 번거로움이 있습니다

 

이번글에서는 oauth2-resource-server를 사용하면 간단하게 JWT 인증설정을 해보겠습니다.
일반적으로 Authorization Server에서 Token을 발급하고 Resource Server에서 검증해야 하지만
단일 Server에서 Token 발급 및 검증을 할 것이기 때문에 oauth2-authorization-server까지는 추가하지 않겠습니다.

개발환경

  • 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 security 6

config/security/SecurityConfig.java

@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);
    }

    @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))

                // Session 상태 없이 변경
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // Jwt 인증 설정
                .oauth2ResourceServer(resourceServerConfig -> resourceServerConfig
                        .jwt(Customizer.withDefaults()))

                // Endpoint 권한 설정
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(HttpMethod.GET, "/api/v3/api-docs/*").permitAll()
                        .requestMatchers(HttpMethod.GET, "/swagger-ui/*").permitAll()
                        .requestMatchers(HttpMethod.GET, "/h2/**").permitAll()
                        .requestMatchers("/api/auth/**").permitAll()
                        .anyRequest().authenticated()
                )

                .build();
    }

    @Bean
    JWKSource<SecurityContext> jwkSource() {
        try {
            var keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048, new SecureRandom());
            var keyPair = keyPairGenerator.genKeyPair();
            var publicKey = (RSAPublicKey) keyPair.getPublic();
            var privateKey = (RSAPrivateKey) keyPair.getPrivate();
            var rsaKey = new RSAKey
                    .Builder(publicKey)
                    .privateKey(privateKey)
                    .keyID(UUID.randomUUID().toString())
                    .build();
            var jwkSet = new JWKSet(rsaKey);
            return new ImmutableJWKSet<>(jwkSet);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Bean
    JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        var jwtProcessor = new DefaultJWTProcessor<>();
        var jwsKeySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.Family.RSA, jwkSource);
        jwtProcessor.setJWSKeySelector(jwsKeySelector);
        return new NimbusJwtDecoder(jwtProcessor);
    }

    @Bean
    JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
        return new NimbusJwtEncoder(jwkSource);
    }
}

 

코드가 좀 길어졌습니다.

 

  • 인증, Form Login, ifrmae 접근 코드 비활성화 처리
    더 이상 필요하지 않으므로 아래의 코드들은 비활성화 처리 했습니다

 

  • Session 상태 없이 변경
    JWT 방식으로 사용할 것이므로 더 이상 세션 상태를 관리하지 않아도 됩니다

 

  • JWT 인증 설정
    앞으로 JWT값으로 인증할 것이므로 인증을 활성화 해줍니다

  • Endpoint 권한 설정
    swagger, h2를 인증/인가 없이 사용하기위해 일부경로는 접근가능하도록 설정했습니다
    추가로 token을 발급 받기 위한 auth api 의 경로도 추가했습니다

  • RSA 키 생성
    jwt는 헤더(header), 페이로드(payload), 서명(signature)의 3부분으로 구성됩니다
    서명 부분은 JWT의 무결성과 진위성을 검증하는 데 사용됩니다
    이를 위해서는 비밀키를 사용하여 서명을 생성하고, 공개키를 사용하여 서명을 검증할 수 있어야 합니다
    그래서 RSA 키를 생성합니다
    서버를 시작할때마다 새롭게 키를 생성하기 때문에
    실제 운영에 사용하려면 별도의 저장소에서 관리해주세요

  • 암호화/복호화 빈 등록
    위에서 생성한 RSA 키로 jwt 토큰을 생성/검증 하기 위해 bean에 등록해줍니다

 

config/WebMvcConfig.java

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void configurePathMatch(PathMatchConfigurer configure) {
        configure.addPathPrefix("api", HandlerTypePredicate.forAnnotation(RestController.class));
    }
}

 

@RestController 어노테이션으로 선언되어 있으면 경로에 접두사로 api 를 붙이도록 설정했습니다.

 

controller/SensorController.java

...
// api/를 삭제해주세요
@RequestMapping("sensor")
...

 

config/SwaggerConfig.java

@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI openAPI() {
        final var securityRequirement = new SecurityRequirement().addList("JWT");
        final var components = new Components()
                .addSecuritySchemes(
                        "JWT",
                        new SecurityScheme()
                                .name("JWT")
                                .type(SecurityScheme.Type.HTTP)
                                .scheme("bearer")
                                .bearerFormat("JWT")
                );
        return new OpenAPI()
                .addServersItem(new Server().url("/"))
                .addSecurityItem(securityRequirement)
                .components(components);
    }

    @Bean
    public GroupedOpenApi authGroup() {
        return GroupedOpenApi.builder()
                .group("auth")
                .pathsToMatch("/api/auth/**")
                .build();
    }

    @Bean
    public GroupedOpenApi commonGroup() {
        return GroupedOpenApi.builder()
                .group("common")
                .pathsToMatch("/api/**")
                .pathsToExclude("/api/auth/**")
                .build();
    }
}

 

jwt 인증을 위한 설정을 추가 했습니다

추가로 아래의 authGroup 과 commonGroup은
인증 없이 실행되는 api와 인증이 필요한 api를 구분하려고 추가했습니다(안하셔도 됩니다)

 

controller/AuthController.java

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

    @GetMapping
    public String getToken() {
        return createToken();
    }

    @GetMapping("extract-subject")
    public String decode(@RequestParam("token") String token) {
        final var jwt = jwtDecoder.decode(token);
        return jwt.getSubject();
    }

    private String createToken() {
        final var jwtClaimsSet = JwtClaimsSet.builder()
                .issuer("middle-developer.tistory.com")
                .issuedAt(Instant.now())
                .subject("admin")
                .expiresAt(Instant.now().plusSeconds(60 * 60 * 24))
                .build();

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

 

토큰 발급을 위한 api, 토큰에서 subject를 추출해보기 위한 api를 추가해봅니다.

issuer는 도메인(발급기관), subject에는 사용자id(발급요청자)를 넣어야 합니다

subject는 db에서 사용자 정보를 불러와서 로그인을 할때 수정해보고

이번에는 단순히 admin으로 하드코딩하겠습니다

 

테스트

모든 요청에 대해 인증이 필요하도록 설정했기때문에
기존처럼 http://localhost/로 접속하려하면 401 코드가 보일것입니다
http://localhost/swagger-ui/index.html로 접속해주세요

 

JWT 발급

 

JWT에서 subject 추출

 

인증 테스트

Select a definition을 common으로 맞춰주세요

 

그 다음 API를 테스트를 하면 401 에러가 발생합니다

 

이를 해결하기 위해서는 우측 상단에 Authorize 초록색 버튼이나 API 우측에 자물쇠 버튼을 눌러서

이전에 발급받은 JWT를 입력해야합니다

 

그리고 API를 테스를 해보면 401 에러 없이 API가 정상적으로 실행됩니다

 

서버 재시작해서 새로운 토큰값으로 인증

현재 만들어진 인증서는 서버가 시작할때 새롭게 만든것이므로

서버가 재시작 할때마다 인증서가 갱신됩니다

이 뜻은 기존에 발급했던 JWT를 사용하지 못한다는 뜻입니다

인증서의 키값이 다르니 복호화가 불가능하기 때문입니다

이를 방지하기 위해서는 인증서를 안전한곳에 보관하시고 불러오셔야합니다

위 문제에 대한 간단한 해결책은 배포글에서 다루겠습니다

 

 

다음 글에서는 JPA를 통해 DB에 있는 사용자 정보를 기반으로 로그인처리를 해보겠습니다

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

Spring Boot 3 Tutorial - 6 Login  (0) 2024.04.25
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 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

 

참조

QueryDsl

현재 Sensor API는 검색조건이 전혀 없습니다.

검색조건을 추가하기 위해 QueryDsl을 사용하겠습니다.

JPQL이나 JPA Criteria를 사용하면 검색 조건을 추가할 수 있지만, 이 방식들에는 한계가 있습니다.

JPQL과 JPA Criteria는 문자열 기반의 쿼리 작성 방식을 사용합니다. 이로 인해 쿼리를 작성하는 데 번거로움이 있으며, 오류를 발견하기가 어렵습니다. 또한 복잡한 쿼리를 작성해야 하는 경우 유지보수가 어려워질 수 있습니다.

 

반면, QueryDsl은 이러한 JPQL과 JPA Criteria의 단점을 보완할 수 있습니다

QueryDsl은 타입 기반의 쿼리 작성 방식을 사용하므로, 컴파일 시점에 오류를 발견할 수 있습니다.

또한 동적 쿼리 생성을 위한 풍부한 API를 제공하여, 유연하고 가독성 높은 쿼리 작성이 가능합니다.

하지만 집계내거나 복잡한 쿼리에서는 한계가 있으니 상황에 맞춰 사용해주세요

 

개발 환경

  • Spring Boot 3.2.4
  • Spring Data Jpa 3.2.4
  • QueryDsl 5.1

환경 설정

intellij

File > Settings > Build, Execution, Deployment > Build Tools > Gradle

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'
}

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

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

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'
}

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

// Querydsl 설정 시작
def generated = 'src/main/generated'

tasks.withType(JavaCompile).configureEach {
    options.generatedSourceOutputDirectory = file(generated)
}

sourceSets {
    main.java.srcDirs += "$projectDir/$generated"
}

clean {
    delete file('src/main/generated')
}
// Querydsl 설정 끝

 

위 설정을 마치셨으면 ./gradlew build 시 generated 폴더가 생성됩니다

여기에 QueryDsl이 만든 도메인 특화 언어(DSL: Domain Specific Language) 파일인 QClass 들이 생성됩니다.

 

entity.Sensor.java

변경된건 없지만 이전글을 안보는 경우도 있을 것 같아 넣어뒀습니다

@Entity
@Getter
@Setter
@Table(name = "sensors")
public class Sensor {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String code;
    private String name;
    private String type; // 온도, 습도, 전력, 움직임 등
}

config.QuerydslConfig.java

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

dto.SensorSelectRequest.java

id, code, type은 여러개를 받을 수 있도록 배열로 선언하고

이름은 일부 포함하는 like 문을 사용하기 위해 아래처럼 구성했습니다

@Getter
@Setter
public class SensorSelectRequest {
    private Long[] id;
    private String[] code;
    private String[] type;
    private String likeName;
}

 

expression.SensorExpression.java

검색조건을 위한 클래스생성은 안해도 되지만, 재사용성을 위해서 만들어 놓고 쓰는걸 추천드립니다

@RequiredArgsConstructor
public class SensorExpression {
    private final QSensor entity;

    public BooleanExpression inId(Long... param) {
        return param == null ? null : entity.id.in(param);
    }

    public BooleanExpression inCode(String... param) {
        return param == null ? null : entity.code.in(param);
    }

    public BooleanExpression inType(String... param) {
        return param == null ? null : entity.type.in(param);
    }

    public BooleanExpression likeName(String param) {
    	// import org.apache.commons.lang3.StringUtils;
        return StringUtils.isEmpty(param) ? null : entity.name.contains(param);
    }
}

 

repository.SensorQueryRepository.java

@Repository
@RequiredArgsConstructor
public class SensorQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public List<Sensor> select(SensorSelectRequest param) {
        var sensor = QSensor.sensor;
        var sensorQueryExpression = new SensorExpression(sensor);
        return jpaQueryFactory
                .selectFrom(sensor)
                .where(
                        sensorQueryExpression.inId(param.getId()),
                        sensorQueryExpression.inCode(param.getCode()),
                        sensorQueryExpression.inType(param.getType()),
                        sensorQueryExpression.likeName(param.getLikeName())
                )
                .fetch();
    }
}

 

service.SensorService.java

@Service
@Transactional
@RequiredArgsConstructor
public class SensorService {

    private final SensorRepository sensorRepository;
    private final SensorQueryRepository sensorQueryRepository;

    public List<Sensor> select(SensorSelectRequest param) {
        return sensorQueryRepository.select(param);
    }

    public Sensor create(Sensor param) {
        return sensorRepository.save(param);
    }

    public Sensor update(Sensor param) {
        return sensorRepository.save(param);
    }

    public boolean delete(Long param) {
        sensorRepository.deleteById(param);
        return true;
    }
}

 

controller.SensorController.java

@RestController
@RequestMapping("api/sensor")
@RequiredArgsConstructor
public class SensorController {

    private final SensorService sensorService;

    @GetMapping
    public List<Sensor> select(@ParameterObject SensorSelectRequest param) {
        return sensorService.select(param);
    }

    @PostMapping
    public Sensor create(@RequestBody Sensor param) {
        return sensorService.create(param);
    }

    @PutMapping
    public Sensor update(@RequestBody Sensor param) {
        return sensorService.update(param);
    }

    @DeleteMapping("{id}")
    public boolean delete(@PathVariable("id") Long param) {
        return sensorService.delete(param);
    }
}

 

확인

 

검색조건이 동작하는것을 확인할 수 있습니다

 

코드는 GitHub - ahnjehoon/spring-boot3-tutorial 여기서 확인 가능합니다

'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 - 3 DBMS switch  (0) 2024.04.04
Spring Boot 3 Tutorial - 2 CRUD  (0) 2024.04.04
Spring Boot 3 Tutorial - 1 Hello World  (0) 2024.04.04

DBMS switch

JPA(Java Persistence API)를 사용할 때 주요 장점 중 하나는 
특정 데이터베이스에 종속되지 않고 개발할 수 있다는 점입니다. 
이는 벤더 독립성(Vendor Independence)이라는 특성 덕분입니다. 
JPA 구현체인 Hibernate가 각 DBMS에 맞는 쿼리로 변환해주기 때문에 
Connection 설정만 변경하면 간단하게 DBMS를 전환할 수 있습니다.

 

이번에는 DBMS를 변경해보겠습니다. 
지금까지는 데이터를 메모리에서 관리하여 서버를 종료하면 데이터가 사라졌습니다. 
물론 H2 DB도 데이터를 삭제하지 않고 계속 사용할 수 있습니다. 
그렇지만 실무에서 많이 사용되는 MYSQL과 MSSQL로 테스트를 진행해보겠습니다.

 

개발 환경

  • Windows 11 home 22631.3296
  • Docker desktop 4.28.0 (139021)

이 글은 Docker Desktop이 설치되어있다는 가정하에 진행합니다.

여기서 주의사항은 DBMS 설치 이후에 사용할 Database를 생성해야합니다

기존에 사용중이신 DBMS가 있을까봐 기본 포트에 1을 붙였습니다. 필요없으신 분들은 빼주세요

MYSQL 설치

먼저 명령 프롬프트창을 열고 다음 명령어를 복사하고 붙여넣기 합니다
단축키: WIN + R => cmd

docker run ^
-e MYSQL_ROOT_PASSWORD=P@ssw0rd ^
-p 13306:3306 ^
-d ^
--name mysql ^
mysql ^
--character-set-server=utf8mb4 ^
--collation-server=utf8mb4_unicode_ci

 

MYSQL Database 생성

docker ps 명령어로 mysql의 CONTAINER ID 를 확인합니다

 

저의 경우는 7b88f66d7a2c이 나왔습니다

다음 명령어를 실행합니다(ID 값 일부만 쳐도 됩니다. 7b8 이렇게요)

# docker exec -it <CONTAINER ID> bash
docker exec -it 7b88f66d7a2c bash
mysql -u root -p
# P@ssw0rd 비밀번호 입력하세요
CREATE DATABASE `test`;

 

MSSQL 설치

명령프롬프트창에 다음 명령어 붙여넣어주세요

docker run ^
-e "ACCEPT_EULA=Y" ^
-e "MSSQL_SA_PASSWORD=P@ssw0rd" ^
-p 11433:1433 ^
-d ^
--name sql_server_developer2022 ^
mcr.microsoft.com/mssql/server:2022-latest

 

Database 생성

위에서 docker ps 로 조회했을때 MSSQL ID는 905749e91f07 

#docker exec -it <CONTAINER ID> /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "P@ssw0rd"
docker exec -it 905749e91f07 /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "P@ssw0rd"
CREATE DATABASE test
GO

 

코드에서 DBMS 변경

MYSQL과 MSSQL 설치가 끝났으면 이제는 코드로 돌아가서 DBMS를 변경해봅시다.

먼저 연결을 위한 라이브러리들을 추가해주세요

build.gradle

...
dependencies {
	...
	runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
	runtimeOnly 'com.mysql:mysql-connector-j'
}
...

 

resources.application.properties

MYSQL용

server.port=80
spring.application.name=tutorial
springdoc.swagger-ui.path=/
# jpa config
spring.jpa.hibernate.ddl-auto=update
## h2-console
#spring.h2.console.enabled=true
#spring.h2.console.path=/h2
## h2-database
#spring.datasource.driver-class-name=org.h2.Driver
#spring.datasource.url=jdbc:h2:mem:test
#spring.datasource.username=sa
#spring.datasource.password=
# mysql
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:13306/test?characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=P@ssw0rd
## sqlserver
#spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
#spring.datasource.url=jdbc:sqlserver://localhost:11433;databaseName=test;trustServerCertificate=true;characterEncoding=UTF-8;
#spring.datasource.username=sa
#spring.datasource.password=P@ssw0rd

MSSQL용

server.port=80
spring.application.name=tutorial
springdoc.swagger-ui.path=/
# jpa config
spring.jpa.hibernate.ddl-auto=update
## h2-console
#spring.h2.console.enabled=true
#spring.h2.console.path=/h2
## h2-database
#spring.datasource.driver-class-name=org.h2.Driver
#spring.datasource.url=jdbc:h2:mem:test
#spring.datasource.username=sa
#spring.datasource.password=
## mysql
#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#spring.datasource.url=jdbc:mysql://localhost:13306/test?characterEncoding=UTF-8
#spring.datasource.username=root
#spring.datasource.password=P@ssw0rd
# sqlserver
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
spring.datasource.url=jdbc:sqlserver://localhost:11433;databaseName=test;trustServerCertificate=true;characterEncoding=UTF-8;
spring.datasource.username=sa
spring.datasource.password=P@ssw0rd

 

 

코드는 다음 주소에서 확인 가능합니다

ahnjehoon/spring-boot3-tutorial (github.com)

'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 - 2 CRUD  (0) 2024.04.04
Spring Boot 3 Tutorial - 1 Hello World  (0) 2024.04.04

CRUD

이번에는 DB와 연동 테스트를 진행해보겠습니다

Controller - Service - Repository 로 구성되는 3 Tier 구조로 작성할예정입니다

 

application.properties

h2 memory 기반 db를 사용하겠습니다

추가로 h2 console을 활성화 하겠습니다

server.port=80
spring.application.name=tutorial
springdoc.swagger-ui.path=/
# h2-console
spring.h2.console.enabled=true
spring.h2.console.path=/h2
# h2-database
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.username=sa
spring.datasource.password=

 

entity.Sensor.java

예제를 어떤 주제로 만들까 생각하다가 간단한 홈 네트워크 구조를 만들려고 합니다.(변경될 수 있습니다)

먼저 튜토리얼 끝내놓고 다시 생각해보겠습니다

일단 그래서 근간이 되는 센서 테이블을 작성합니다

@Entity
@Getter
@Setter
@Table(name = "sensors")
public class Sensor {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String code;
    private String name;
    private String type; // 온도, 습도, 전력, 움직임 등
}

 

 

repository.SensorRepository.java

public interface SensorRepository extends JpaRepository<Sensor, Long> {
}

 

service.SensorService.java

이번 글에서는 편의상 Entity Class를 매개변수로 넣지만 이렇게 쓰지 말아주세요

@Service
@Transactional
@RequiredArgsConstructor
public class SensorService {
    private final SensorRepository sensorRepository;

    public List<Sensor> select() {
        return sensorRepository.findAll();
    }

    public Sensor create(Sensor param) {
        return sensorRepository.save(param);
    }

    public Sensor update(Sensor param) {
        return sensorRepository.save(param);
    }

    public boolean delete(Long param) {
        sensorRepository.deleteById(param);
        return true;
    }
}

 

controller.SensorController.java

@RestController
@RequestMapping("api/sensor")
@RequiredArgsConstructor
public class SensorController {
    private final SensorService sensorService;

    @GetMapping
    public List<Sensor> select() {
        return sensorService.select();
    }

    @PostMapping
    public Sensor create(@RequestBody Sensor param) {
        return sensorService.create(param);
    }

    @PutMapping
    public Sensor update(@RequestBody Sensor param) {
        return sensorService.update(param);
    }

    @DeleteMapping("{id}")
    public boolean delete(@PathVariable("id") Long param) {
        return sensorService.delete(param);
    }
}

 

이제 localhost로 접속해서 POST 요청을 날려 데이터를 생성해보거나

PUT 요청을 날려 데이터를 수정해보세요

 

http://localhost/h2/login.jsp 로 접속해서

 

 

위 화면이 나오는데 위 처럼 입력하고 connect를 누르면 다음과 같은 화면이 나옵니다

 

여기서 좌측 상단에 보이는 SENSORS를 클릭하고
Run 또는 Ctrl + Enter 키를 누르면 POST 요청으로 전송했던 데이터를 확인할 수 있습니다.

 

 

몇줄 작성하지도 않았는데 CRUD가 완성됐습니다.

 

코드는 다음 github 주소에서 확인할 수 있습니다

 

ahnjehoon/spring-boot3-tutorial (github.com)

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