존댓말한다? 그러면 아마 공부한 거 번역해놓은 자료이다.. 밑에 내려가면 내가 작성한 코드들이..주루룩..
Spring Security는 웹 애플리케이션의 보안을 손쉽게 설정할 수 있도록 도와주는 프레임워크입니다. 이 문서에서는 Spring Boot와 함께 Spring Security를 사용하는 기본 설정을 알아보고, 이후 어떤 단계를 거쳐야 하는지 안내하겠습니다.
Spring Security를 사용하려면 먼저 애플리케이션의 클래스패스에 Spring Security 라이브러리를 추가해야 합니다. Maven이나 Gradle을 사용해 프로젝트에 추가할 수 있습니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
}
Spring Security가 클래스패스에 추가된 후, 이제 Spring Boot 애플리케이션을 실행할 수 있습니다. 아래는 Spring Security가 애플리케이션에서 활성화된 것을 보여주는 콘솔 출력의 일부입니다:
$ ./mvnw spring-boot:run
...
INFO 23689 --- [ restartedMain] .s.s.UserDetailsServiceAutoConfiguration :
Using generated security password: 8e557245-73e2-4286-969a-ff57fe326336
이제 애플리케이션이 실행되었으니, 인증 없이 엔드포인트를 호출해보면 어떤 일이 발생하는지 확인할 수 있습니다:
$ curl -i <http://localhost:8080/some/path>
HTTP/1.1 401
Spring Security는 401 Unauthorized 응답으로 접근을 거부합니다. 동일한 URL을 브라우저에서 요청하면 기본 로그인 페이지로 리디렉션됩니다.
이제 콘솔 출력에서 제공된 자격 증명을 사용하여 요청을 시도할 수 있습니다:
$ curl -i -u user:8e557245-73e2-4286-969a-ff57fe326336 <http://localhost:8080/some/path>
HTTP/1.1 404
이 경우, /some/path
엔드포인트가 존재하지 않으므로 404 Not Found 응답이 반환됩니다.
Spring Boot와 Spring Security의 기본 설정은 다음과 같은 런타임 동작을 제공합니다:
CSRF 공격
을 방지합니다.사용자 인증: 사용자가 웹 애플리케이션에 로그인하면, 서버는 사용자의 브라우저에 세션 쿠키를 설정합니다. 이 쿠키는 이후의 요청에서 사용자를 인증하는 데 사용됩니다.
악성 웹사이트 방문: 공격자는 사용자가 방문할 수 있는 악성 웹사이트나 이메일 링크를 준비합니다. 이 사이트나 링크에는 공격자가 미리 설계한 악성 요청이 포함되어 있습니다.
자동 요청 실행: 사용자가 악성 웹사이트를 방문하면, 사용자의 브라우저는 로그인된 상태에서 해당 웹 애플리케이션의 서버로 악성 요청을 자동으로 보냅니다. 이때, 브라우저는 자동으로 세션 쿠키를 함께 전송합니다.
서버의 응답: 서버는 요청을 받은 후, 이를 사용자가 의도한 요청으로 인식하고 처리하게 됩니다. 예를 들어, 사용자의 은행 계좌에서 다른 계좌로 돈을 이체하거나, 사용자의 계정을 삭제하는 등의 작업이 수행될 수 있습니다.
가령, 사용자가 은행 사이트에 로그인한 상태에서 공격자가 만든 악성 웹사이트를 방문했다고 가정합니다. 이 웹사이트에는 다음과 같은 이미지 태그가 포함될 수 있습니다:
<img src="<http://bank.com/transfer?amount=10000&to_account=attacker_account>">
이 이미지는 실제로 존재하지 않지만, 브라우저는 이를 로드하기 위해 src
속성의 URL로 요청을 보내게 됩니다. 이 요청은 사용자가 인증된 상태이기 때문에 서버는 이를 정상적인 요청으로 처리할 수 있습니다. 결과적으로 사용자는 의도치 않게 자신의 계좌에서 공격자의 계좌로 돈을 이체하게 됩니다.
CSRF 공격을 방지하기 위해 Spring Security와 같은 프레임워크에서는 다음과 같은 방법을 사용합니다:
CSRF 토큰 사용: 서버는 각 요청마다 유일한 CSRF 토큰을 발급하고, 이 토큰을 숨겨진 폼 필드나 HTTP 헤더에 포함하여 요청합니다. 서버는 요청 시 제공된 토큰과 세션에 저장된 토큰을 비교하여 일치하지 않으면 요청을 거부합니다.
Referer 또는 Origin 헤더 검사: 서버는 요청의 출처를 확인하여, 허용된 출처에서 온 요청만을 처리합니다.
SameSite 쿠키 속성: 쿠키에 SameSite
속성을 설정하여, 동일한 사이트에서의 요청이 아닌 경우 쿠키가 전송되지 않도록 할 수 있습니다.
이러한 보안 조치들을 통해 CSRF 공격으로부터 애플리케이션과 사용자를 보호할 수 있습니다.
HttpServletRequest
의 인증 메서드와 통합됩니다.Spring Boot의 보안 자동 구성은 다음과 같은 작업을 수행합니다:
@EnableWebSecurity
애노테이션을 추가하여 Spring Security의 기본 필터 체인을 @Bean
으로 등록합니다.user
이고 콘솔에 로그된 임의의 비밀번호를 사용하여 UserDetailsService
를 @Bean
으로 게시합니다.AuthenticationEventPublisher
를 @Bean
으로 게시합니다.Spring Boot는 @Bean
으로 게시된 모든 필터를 애플리케이션의 필터 체인에 추가합니다. 따라서 @EnableWebSecurity
와 Spring Boot를 함께 사용하면 모든 요청에 대해 자동으로 Spring Security의 필터 체인이 등록됩니다.
Spring Security는 다양한 보안 요구사항을 충족하기 위해 설계되었습니다. 애플리케이션에 맞는 다음 단계를 고려해보세요:
각 상황에 맞는 보안 설정을 고려하며, 필요에 따라 Spring Security의 기본 보호 기능을 통합하고 추가적인 보호 기능을 설정할 수 있습니다.
이와 같은 방식으로 Spring Security와 Spring Boot를 조합하여 간단하고 안전한 웹 애플리케이션을 구축할 수 있습니다.
이 글에서는 Spring Security와 Spring Boot를 사용하여 OAuth2 Resource Server를 설정하고, JWT(JSON Web Token)를 이용한 인증 방법을 설명하겠습니다. 이 과정에서 필요한 모든 설정과 코드를 포함하여 최대한 쉽게 이해할 수 있도록 설명하겠습니다.
JWT는 JSON 형식으로 데이터를 안전하게 전송하기 위한 표준입니다. 주로 인증과 권한 부여에 사용됩니다. JWT는 서버에서 생성되며, 서명이 포함되어 있어 클라이언트가 토큰의 무결성을 확인할 수 있습니다.
OAuth2 Resource Server는 JWT 토큰을 받아서 이를 검증하고, 클라이언트의 요청이 유효한지 확인하는 역할을 합니다. 쉽게 말해, 클라이언트가 API에 접근하려면 유효한 JWT를 가지고 있어야 하며, Resource Server는 이 JWT가 정말 신뢰할 수 있는 것인지 확인합니다.
Spring Boot에서 Resource Server를 설정하는 방법은 간단합니다. 두 가지 주요 단계가 있습니다:
1단계. 필요한 의존성 추가
2단계. 인증 서버의 위치 설정
Maven이나 Gradle을 사용해 프로젝트에 필요한 라이브러리를 추가합니다. 예를 들어, Maven을 사용하는 경우 spring-security-oauth2-resource-server
와 spring-security-oauth2-jose
를 추가해야 합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.security:spring-security-oauth2-jose'
Spring Boot 애플리케이션에서 인증 서버의 위치를 지정하려면 application.yml
파일에 다음과 같이 설정합니다:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: <https://idp.example.com/issuer>
여기서 issuer-uri
는 JWT 토큰이 발급된 인증 서버의 URI입니다. Spring Boot는 이 정보를 기반으로 JWT 토큰을 검증합니다.
애플리케이션이 시작되면, Resource Server는 다음과 같은 절차를 통해 JWT 토큰을 검증할 준비를 합니다:
iss
(발급자) 클레임과 공개 키를 기반으로 서명을 검증합니다.이 과정에서 인증 서버가 정상적으로 동작 중이어야 하며, 그렇지 않으면 애플리케이션 시작이 실패할 수 있습니다.
애플리케이션이 시작된 후, Resource Server는 Authorization
헤더에 Bearer
토큰을 포함한 요청을 처리합니다:
GET / HTTP/1.1
Authorization: Bearer some-token-value
Resource Server는 Bearer
토큰이 포함된 요청을 받아 JWT의 서명을 검증하고, 토큰이 유효한지 확인합니다. 유효한 토큰인 경우, 요청을 처리하고 필요한 경우 사용자 정보를 반환합니다.
exp
(만료 시간), nbf
(Not Before 시간), iss
(발급자) 클레임 등을 검증하여 토큰의 유효성을 확인합니다.scope
또는 scp
클레임을 기반으로 권한을 설정합니다.Spring Boot는 기본적으로 모든 요청에 대해 인증을 요구하며, JWT 토큰을 사용해 인증을 수행합니다. 기본 설정은 다음과 같습니다:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
이 기본 설정을 통해 모든 요청이 인증을 거치도록 설정되며, JWT 토큰을 사용한 인증이 자동으로 처리됩니다.
인증 서버가 메타데이터 엔드포인트를 지원하지 않거나, Resource Server가 인증 서버와 독립적으로 동작해야 하는 경우 jwk-set-uri
를 직접 설정할 수 있습니다:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: <https://idp.example.com>
jwk-set-uri: <https://idp.example.com/.well-known/jwks.json>
이렇게 설정하면 Resource Server는 애플리케이션 시작 시 인증 서버를 쿼리하지 않고, 지정된 URI에서 JWK를 가져와 JWT 서명을 검증합니다.
Spring Boot는 기본적으로 issuer-uri
를 사용하여 JWT를 검증하지만, 더 깊이 있는 설정이 필요한 경우 직접 JwtDecoder
를 설정할 수 있습니다.
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation(issuerUri);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("<https://idp.example.com/.well-known/jwks.json>")
)
);
return http.build();
}
더 강력한 설정이 필요하다면, decoder()
메서드를 사용하여 Spring Boot의 기본 설정을 완전히 대체할 수 있습니다:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(myCustomDecoder())
)
);
return http.build();
}
또한 JwtDecoder
를 @Bean
으로 직접 노출시킬 수 있습니다:
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("<https://idp.example.com/.well-known/jwks.json>").build();
}
기본적으로 NimbusJwtDecoder
는 RS256
알고리즘만 신뢰하며 검증을 수행합니다. 필요한 경우 이를 커스터마이징할 수 있습니다.
spring:
security:
oauth2:
resourceserver:
jwt:
jws-algorithms: RS512
jwk-set-uri: <https://idp.example.org/.well-known/jwks.json>
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withIssuerLocation(issuerUri)
.jwsAlgorithm(RS512)
.build();
}
여러 알고리즘을 지원하려면 다음과 같이 설정할 수 있습니다:
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withIssuerLocation(issuerUri)
.jwsAlgorithms(algorithms -> {
algorithms.add(RS512);
algorithms.add(ES512);
}).build();
}
JWK Set 엔드포인트 대신 RSA 공개 키를 직접 설정할 수 있습니다. Spring Boot 설정을 사용하거나 빌더를 사용할 수 있습니다.
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:my-key.pub
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.key).build();
}
JWT는 scope
또는 scp
속성을 사용하여 사용자에게 부여된 권한을 나타냅니다. Resource Server는 이 권한을 사용하여 접근 제어를 수행합니다.
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;
@Configuration
@EnableWebSecurity
public class MyCustomSecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/messages/**").access(hasScope("message:read"))
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(myConverter())
)
);
return http.build();
}
}
@PreAuthorize("hasAuthority('SCOPE_messages')")
public List<Message> getMessages(...) {
// 메소드 내용
}
일부 경우 기본 설정만으로는 충분하지 않을 수 있습니다. 예를 들어, 권한이 scope
클레임이 아닌 사용자 정의 클레임에 있을 때가 그렇습니다.
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
기본적으로 Spring Security는 SCOPE_
접두사를 권한에 추가하지만, 이를 변경할 수 있습니다:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
접두사를 아예 제거하려면 다음과 같이 설정합니다:
grantedAuthoritiesConverter.setAuthorityPrefix("");
또한, 커스텀 권한 변환기를 사용할 수도 있습니다:
static class CustomAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
public AbstractAuthenticationToken convert(Jwt jwt) {
return new CustomAuthenticationToken(jwt);
}
}
@Configuration
@EnableWebSecurity
public class CustomAuthenticationConverterConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(new CustomAuthenticationConverter())
)
);
return http.build();
}
}
기본적으로 Spring Boot는 issuer-uri
를 사용하여 iss
, exp
, nbf
클레임을 검증합니다. 필요에 따라 이 검증 로직을 커스터마이징할 수 있습니다.
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
JwtDecoders.fromIssuerLocation(issuerUri);
OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(60)),
new JwtIssuerValidator(issuerUri));
jwtDecoder.setJwtValidator(withClockSkew);
return jwtDecoder;
}
특정 aud
클레임을 검증하기 위해 OAuth2TokenValidator
를 사용할 수 있습니다:
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtClaimValidator<List<String>>(AUD, aud -> aud.contains("messaging"));
}
더 정밀한 검증이 필요하다면 직접 검증기를 구현할 수 있습니다:
static class AudienceValidator implements OAuth2TokenValidator<Jwt> {
OAuth2Error error = new OAuth2Error("custom_code", "Custom error message", null);
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains("messaging")) {
return OAuth2TokenValidatorResult.success();
} else {
return OAuth2TokenValidatorResult.failure(error);
}
}
}
이를 JWT 디코더에 적용하려면 다음과 같이 설정합니다:
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = (NimbusJwtDecoder)
JwtDecoders.fromIssuerLocation(issuerUri);
OAuth2TokenValidator<Jwt> audienceValidator = audienceValidator();
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
jwtDecoder.setJwtValidator(withAudience);
return jwtDecoder;
}
Resource Server는 MappedJwtClaimSetConverter
를 사용하여 JWT 클레임을 변환할 수 있습니다. 이를 통해 클레임을 추가하거나, 제거하거나, 이름을 변경할 수 있습니다.
기본 변환 전략을 사용하면서 특정 클레임의 변환만 수정할 수 있습니다:
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter
.withDefaults(Collections.singletonMap("sub", this::lookupUserIdBySub));
jwtDecoder.setClaimSetConverter(converter);
return jwtDecoder;
}
클레임을 추가하는 방법도 있습니다:
MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("custom", custom -> "value"));
기존 클레임을 제거하려면 다음과 같이 설정합니다:
MappedJwtClaimSetConverter.withDefaults(Collections.singletonMap("legacyclaim", legacy -> null));
여러 클레임을 조합하거나 이름을 변경해야 하는 경우, 다음과 같이 설정할 수 있습니다:
public class UsernameSubClaimAdapter implements Converter<Map<String, Object>, Map<String, Object>> {
private final MappedJwtClaimSetConverter delegate =
MappedJwtClaimSetConverter.withDefaults(Collections.emptyMap());
public Map<String, Object> convert(Map<String, Object> claims) {
Map<String, Object> convertedClaims = this.delegate.convert(claims);
String username = (String) convertedClaims.get("user_name");
convertedClaims.put("sub", username);
return convertedClaims;
}
}
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter());
return jwtDecoder;
}
Resource Server는 기본적으로 인증 서버와의 연결 및 소켓 타임아웃을 30초로 설정합니다. 더 긴 타임아웃이 필요하거나, 복잡한 백오프 및 디스커버리 패턴을 구현하려면 다음과 같이 설정할 수 있습니다:
@Bean
public JwtDecoder jwtDecoder(RestTemplateBuilder builder) {
RestOperations rest = builder
.setConnectTimeout(Duration.ofSeconds(60))
.setReadTimeout(Duration.ofSeconds(60))
.build();
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(rest).build();
return jwtDecoder;
}
기본적으로 Resource Server는 인증 서버의 JWK 세트를 메모리에 5분간 캐시합니다. 더 정밀한 캐싱 설정이 필요할 경우 다음과 같이 캐시 설정을 할 수 있습니다:
@Bean
public JwtDecoder jwtDecoder(CacheManager cacheManager) {
return NimbusJwtDecoder.withIssuerLocation(issuer)
.cache(cacheManager.getCache("jwks"))
.build();
}
캐시를 사용하는 경우, JWK Set URI를 키로 사용하고 JWK Set JSON을 값으로 사용합니다.
우선 난 spring-data-jpa, mysql 8.3.0 (docker-compose로 올린), 그리고 spring-security 등을 사용하여 로그인/로그아웃/회원가입을 먼저 구현해보고자 한다
HttpSecurity - spring-security-config 6.2.0 javadoc
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.3'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.mylittleproject'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
jar {
enabled = false
}
DB → mysql
JWT token 기반 인증방식 사용
우선, 시큐리티 설정에 앞서 프로젝트 디렉토리에 config 폴더, 그 하위에 SecurityConfig.java
파일을 작성한다
package com.mylittleproject.firstlittleproject.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import lombok.RequiredArgsConstructor;
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfiguration {
private final UserDetailsService userService;
//스프링 시큐리티 기능 비활성화 하기
@Bean
public WebSecurityCustomizer configure() {
return (web -> web.ignoring()
.requestMatchers(new AntPathRequestMatcher("/api/**", "GET")));
}
//특정 HTTP 요청에 대한 웹 기반 보안 구성
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/login"),
new AntPathRequestMatcher("/signup"),
new AntPathRequestMatcher("/user"))
.permitAll()
.anyRequest().authenticated())
.formLogin(formLogin -> formLogin
.loginPage("/login")
.defaultSuccessUrl("/articles"))
.logout(logout -> logout
.logoutSuccessUrl("/login")
.invalidateHttpSession(true) // 로그인 후 세션 전체 삭제여부 -> true(전체삭제함)
)
.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화
.build();
}
//인증 관리자 관련 설정하기
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailsService userDetailsService) throws Exception {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userService); //사용자 정보 서비스 설정하기
authProvider.setPasswordEncoder(bCryptPasswordEncoder);
return new ProviderManager(authProvider);
}
//패스워드 인코더로 사용할 빈 등록하기
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
SecurityConfig.java
설명 이 클래스는 Spring Security를 사용하여 웹 애플리케이션의 보안을 설정하는 SecurityConfig
클래스입니다. 주요 기능으로는 특정 HTTP 요청에 대한 보안 설정, 사용자 인증 관리, 그리고 암호 인코딩 방식 설정이 포함되어 있습니다. 이 클래스는 @Configuration
, @EnableWebSecurity
등의 애노테이션을 사용하여 Spring Security 설정을 활성화합니다.클래스 선언
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfiguration {
private final UserDetailsService userService;
}
@Configuration
: 이 클래스가 스프링 설정 클래스임을 나타냅니다.@RequiredArgsConstructor
: Lombok을 사용하여 final
필드를 인자로 받는 생성자를 자동으로 생성합니다. 이 경우 UserDetailsService
타입의 userService
필드를 주입받습니다.@EnableWebSecurity
: Spring Security를 활성화하는 애노테이션으로, 기본적인 웹 보안 기능을 설정합니다.WebSecurityConfiguration
: Spring Security 설정을 제공하는 기본 클래스입니다.WebSecurityCustomizer 설정
@Bean
public WebSecurityCustomizer configure() {
return (web -> web.ignoring()
.requestMatchers(new AntPathRequestMatcher("/api/**", "GET")));
}
WebSecurityCustomizer
는 특정 요청을 Security Filter Chain에서 제외시키는 역할을 합니다."/api/**"
로 시작하는 모든 GET
요청을 보안 검사에서 무시하도록 설정합니다. 즉, 이 경로의 GET
요청은 인증 없이 접근할 수 있습니다.SecurityFilterChain 설정
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/login"),
new AntPathRequestMatcher("/signup"),
new AntPathRequestMatcher("/user"))
.permitAll()
.anyRequest().authenticated())
.formLogin(formLogin -> formLogin
.loginPage("/login")
.defaultSuccessUrl("/articles"))
.logout(logout -> logout
.logoutSuccessUrl("/login")
.invalidateHttpSession(true)
)
.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화
.build();
}
SecurityFilterChain
은 HTTP 보안 설정을 정의합니다.authorizeHttpRequests
: 요청에 대한 접근 제어를 설정합니다. "/login"
, "/signup"
, "/user"
경로에 대한 접근은 인증 없이 허용(permitAll()
)되며, 그 외의 모든 요청은 인증을 요구합니다.formLogin
: 폼 기반 인증을 설정합니다. 로그인 페이지를 "/login"
으로 지정하고, 성공 시 "/articles"
로 리다이렉트합니다.logout
: 로그아웃 설정입니다. 로그아웃 성공 시 "/login"
으로 리다이렉트되며, 세션이 무효화됩니다.csrf(AbstractHttpConfigurer::disable)
: CSRF(Cross-Site Request Forgery) 보호를 비활성화합니다. 일반적으로 CSRF 보호는 활성화된 상태로 유지하는 것이 좋지만, 특정 상황에서는 비활성화할 수 있습니다.AuthenticationManager 설정
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder bCryptPasswordEncoder, UserDetailsService userDetailsService) throws Exception {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userService); //사용자 정보 서비스 설정하기
authProvider.setPasswordEncoder(bCryptPasswordEncoder);
return new ProviderManager(authProvider);
}
AuthenticationManager
: 인증 과정을 관리하는 핵심 구성 요소입니다. DaoAuthenticationProvider
를 사용하여 사용자 인증을 처리합니다.DaoAuthenticationProvider
: UserDetailsService
를 사용해 사용자 정보를 로드하고, PasswordEncoder
를 사용해 암호를 검증합니다.ProviderManager
: DaoAuthenticationProvider
와 같은 AuthenticationProvider
를 관리하는 클래스입니다.BCryptPasswordEncoder 설정
```java
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
```
- `BCryptPasswordEncoder`는 비밀번호를 안전하게 저장하기 위해 사용하는 암호화 방법 중 하나입니다.
- 이 클래스에서는 `BCryptPasswordEncoder`를 빈으로 등록하여 `DaoAuthenticationProvider`에서 사용합니다. 비밀번호는 BCrypt 알고리즘을 통해 해시되어 데이터베이스에 저장됩니다.
WebSecurityCustomizer: 특정 경로("/api/**"
의 GET
요청)를 인증 및 보안 검사에서 제외합니다.
SecurityFilterChain: 폼 기반 로그인 및 로그아웃, 특정 경로에 대한 접근 제어, CSRF 보호 설정을 정의합니다.
AuthenticationManager: 사용자 인증을 처리하는 구성 요소로, DaoAuthenticationProvider
를 사용해 UserDetailsService
와 BCryptPasswordEncoder
를 설정합니다.
BCryptPasswordEncoder: 비밀번호를 안전하게 암호화하는 인코더입니다.
이 클래스는 Spring Security의 다양한 기능을 사용하여 웹 애플리케이션의 보안을 설정하는 예입니다. 각 기능이 어떻게 동작하는지 이해하고, 필요에 따라 설정을 수정하거나 확장할 수 있습니다.
나는 사용자 엔티티에
PK, 사용자 성명, 사용자 이메일, 사용자 비밀번호만 일단! 넣어줄 거라 아래와 같이 entity를 작성하였다.
package com.mylittleproject.firstlittleproject.user.entity;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class User implements UserDetails { //UserDetails 상속받아 인증 객체로 사용함
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", updatable = false, nullable = false)
private Long id;
@Column(name = "user_name", nullable = false)
private String username; //사용자 성명
@Column(name = "email", nullable = false, unique = true)
private String email; //사용자 이메일
@Column(name = "password", nullable = false)
private String password; //사용자 비밀번호
@Builder //빌더 패턴을 사용할 것!
public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//권한 반환하는 로직
return List.of(new SimpleGrantedAuthority("user"));
}
@Override
public boolean isCredentialsNonExpired() {
//패스워드 만료되었나 확인하는 로직
return true; //만료되지 않았음 리턴
}
@Override
public boolean isEnabled() {
//계정 사용 가능 여부 반환
return true; //사용 가능함
}
@Override
public boolean isAccountNonLocked() {
//계정 잠금 여부 반환
return true; //true -> 잠금되지 않았음
}
@Override
public boolean isAccountNonExpired() {
//계정 만료 여부 반환
return true; //true -> 만료되지 않았음
}
}
그 후, UserRepository에
package com.mylittleproject.firstlittleproject.user.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.mylittleproject.firstlittleproject.user.entity.User;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); //email로 사용자 식별하기
}
와 같이 작성해주었고,
스프링 시큐리티 설정을 위해 UserDetailsService를 작성해주었다.
package com.mylittleproject.firstlittleproject.user.service;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.mylittleproject.firstlittleproject.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(email));
}
}
일단
이 페이지를 참고하여.. dto의 타입을 record로 작성하였다!
package com.mylittleproject.firstlittleproject.user.dto;
public record AddUserRequest(
String username,
String email,
String password
) {
}
그리고 나서 service 구현!
package com.mylittleproject.firstlittleproject.user.service;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import com.mylittleproject.firstlittleproject.user.dto.AddUserRequest;
import com.mylittleproject.firstlittleproject.user.entity.User;
import com.mylittleproject.firstlittleproject.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public Long save(AddUserRequest addUserRequestDto) {
return userRepository.save(User.builder()
.username(addUserRequestDto.username())
.email(addUserRequestDto.email())
.password(bCryptPasswordEncoder.encode(addUserRequestDto.password())) //password 인코딩(암호화하기)
.build()).getId();
}
}
나는 타임리프를 쓰지 않으므로..
컨트롤러단은 이렇게만 작성했다! :)
package com.mylittleproject.firstlittleproject.user.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.mylittleproject.firstlittleproject.user.dto.AddUserRequest;
import com.mylittleproject.firstlittleproject.user.service.UserService;
import lombok.RequiredArgsConstructor;
@RequestMapping("/api/user")
@RequiredArgsConstructor
@RestController
public class UserApiController {
private final UserService userService;
@PostMapping("")
public ResponseEntity<String> signup(AddUserRequest request) {
userService.save(request);
return ResponseEntity.ok().body("회원가입에 성공하였습니다. 축하축하");
}
}
우선 컨트롤러에 작성하기!
@GetMapping("/logout")
public ResponseEntity<String> logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response, SecurityContextHolder.getContext().getAuthentication());
return ResponseEntity.ok().body("로그아웃에 성공하였습니다. 축하할 일인가요? 일단 축하드립니다. ㅎㅎ");
}
이제
리액트로 회원가입을 해보고자 하였다..
원래 디자인을 그렇게도 신경쓰던 나지만 오늘만큼은 백엔드 개발자의 프론트 개발..st
// src/App.js
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Main from "./components/Main";
import Signup from "./components/SignupPage";
import Login from "./components/LoginPage";
function App() {
return (
<Router>
<Routes>
<Route path="/signup" element={<Signup />} />
<Route path="/login" element={<Login />} />
<Route path="/" element={<Main />} />
</Routes>
</Router>
);
}
export default App;
이렇게 라우트 설정 먼저 해주고~
import React from "react";
const Login = () => {
return (
<form action="http://localhost:8080/login" method="POST">
<div>
<label>Username:</label>
<input type="text" name="username" />
</div>
<div>
<label>Password:</label>
<input type="password" name="password" />
</div>
<button type="submit">Login</button>
</form>
);
};
export default Login;
→ 로그인 페이지
import React from "react";
const Logout = () => {
const handleLogout = async () => {
try {
const response = await fetch("http://localhost:8080/api/user/logout", {
method: "GET",
credentials: "include", // 쿠키와 함께 요청을 보낼 때 사용
});
if (response.ok) {
alert(
"로그아웃에 성공하였습니다. 축하할 일인가요? 일단 축하드립니다. ㅎㅎ"
);
window.location.href = "http://localhost:3000"; // 로그아웃 후 메인 페이지로 리다이렉트
} else {
alert("로그아웃에 실패하였습니다.");
}
} catch (error) {
console.error("Error:", error);
alert("로그아웃 중 오류가 발생했습니다.");
}
};
return <button onClick={handleLogout}>Logout</button>;
};
export default Logout;
→ 로그아웃 페이지
import React from "react";
import Logout from "./LogoutPage";
const Main = () => {
return (
<div>
<h1>메인 페이지입니다.</h1>
<p>이 페이지에서 로그아웃할 수 있습니다.</p>
{/* 로그아웃 버튼 */}
<Logout />
</div>
);
};
export default Main;
→ 메인 페이지
import React, { useState } from "react";
const Signup = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const handleSignup = async (e) => {
e.preventDefault();
try {
const response = await fetch("http://localhost:8080/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, email, password }),
});
if (response.ok) {
alert("회원가입에 성공하였습니다. 축하축하!");
} else {
alert("회원가입에 실패하였습니다. 다시 시도해주세요.");
}
} catch (error) {
console.error("Error:", error);
alert("회원가입 중 오류가 발생했습니다.");
}
};
return (
<form onSubmit={handleSignup}>
<div>
<label>Username:</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label>Email:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button type="submit">Sign Up</button>
</form>
);
};
export default Signup;
→ 회원가입 페이지
이렇게 하고 이제 회원가입이 되나 해보려 했는데?
CORS..그가 나타나다
그래서 SecurityConfig로 달려가서 에러를 해결했습니다
는 말고 WebConfig에서 했어요..
package com.mylittleproject.firstlittleproject.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:3000")
.allowedMethods("GET", "POST")
.allowCredentials(true); //쿠키 인증 요청 허용
}
}
대충 요렇게.. 해서 작성하였습니다
그러고 회원가입 시도해보니
잘 되었다.. 패스워드까지 잘 암호화가 되었다! :):):)
현재 사진 첨부가 전혀 되질 않아서.. 그냥 이렇게 텍스트랑 코드로만 작성중이다..
로그아웃을 바로 해보려 했으나, 책에서는 타임리프로 구현하고 있어서.. 일단 jwt내용을 학습 후 다시 돌아오도록 하겠음