Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Minsu/umc10th/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jackson'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-validation'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spring-boot-starter-validation이 위에서 이미 선언되어 있어 중복 의존성으로 보입니다. 동작에는 큰 문제가 없을 수 있지만, 빌드 파일은 의존성 의도를 보여주는 문서 역할도 하므로 하나만 남기는 것을 권장합니다.

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'com.h2database:h2'
testCompileOnly 'org.projectlombok:lombok'
Expand Down
21 changes: 21 additions & 0 deletions Minsu/umc10th/keyword_summary/ch08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Spring Security가 무엇인가?

Spring Security는 Spring 기반 애플리케이션에서 인증과 인가를 처리하는 보안 프레임워크다.
요청이 Controller에 도달하기 전에 Security Filter Chain을 먼저 통과하면서 로그인 여부와 권한을 검사한다.
개발자는 `SecurityFilterChain`, `UserDetailsService`, `PasswordEncoder` 같은 구성 요소를 조합해 보안 흐름을 설정한다.
폼 로그인에서는 사용자가 입력한 이메일과 비밀번호를 기반으로 인증하고, 성공한 인증 정보는 `SecurityContext`에 저장된다.

## 인증(Authentication)vs 인가(Authorization)

인증(Authentication)은 “이 사용자가 누구인가?”를 확인하는 과정이다.
예를 들어 이메일과 비밀번호로 로그인해서 DB에 저장된 회원인지 확인하는 흐름이 인증이다.
인가(Authorization)는 “인증된 사용자가 이 기능에 접근할 권한이 있는가?”를 확인하는 과정이다.
로그인하지 않은 사용자가 Private API에 접근하면 인증 실패로 401 응답이 나가고, 권한이 부족하면 인가 실패로 403 응답이 나간다.
Spring Security에서는 인증 후 생성된 `Authentication` 객체의 권한 정보를 바탕으로 인가 판단을 수행한다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증/인가 차이를 401/403과 연결한 점이 좋습니다. 여기에 AuthenticationExceptionAccessDeniedException이 각각 EntryPoint와 AccessDeniedHandler로 이어지는 흐름을 추가하면 코드의 예외 처리와 개념 정리가 더 잘 연결됩니다.


## Stateful vs Stateless

Stateful은 서버가 사용자의 로그인 상태를 세션 등에 저장하는 방식이다.
폼 로그인은 기본적으로 Stateful 방식이며, 로그인 성공 후 서버 세션을 통해 사용자를 계속 식별한다.
Stateless는 서버가 로그인 상태를 저장하지 않고, 클라이언트가 매 요청마다 토큰 같은 인증 정보를 보내는 방식이다.
JWT 인증은 대표적인 Stateless 방식으로, 서버 확장에는 유리하지만 토큰 관리와 만료 처리가 중요하다.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.example.umc10th.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

Expand All @@ -20,7 +21,7 @@ public class AuthController {

@PostMapping("/signup")
@Operation(summary = "회원가입")
public ApiResponse<MemberResDTO.SignUpInfo> signUp(@RequestBody MemberReqDTO.SignUp dto) {
public ApiResponse<MemberResDTO.SignUpInfo> signUp(@RequestBody @Valid MemberReqDTO.SignUp dto) {
return ApiResponse.onSuccess(MemberSuccessCode.CREATED, memberService.signUp(dto));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.example.umc10th.domain.member.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

Expand All @@ -13,6 +16,7 @@ public record GetInfo(

public record SignUp(
@Schema(description = "이름", example = "홍길동")
@NotBlank(message = "이름은 필수입니다.")
String name,
@Schema(description = "성별 (MALE/FEMALE)", example = "MALE")
String gender,
Expand All @@ -21,7 +25,13 @@ public record SignUp(
@Schema(description = "상세주소", example = "서울시 성북구 안암동")
String detailAddress,
@Schema(description = "이메일", example = "user@example.com")
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
String email,
@Schema(description = "비밀번호", example = "password1234")
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.")
String password,
@Schema(description = "전화번호", example = "010-1234-5678")
String phoneNumber,
@Schema(description = "선호 음식 ID 목록", example = "[1, 3]")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public class Member extends BaseEntity {
@Column(name = "email", nullable = false)
private String email;

@Column(name = "password", nullable = false)
private String password;

@Column(name = "profile_url")
private String profileUrl;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.example.umc10th.domain.member.repository.MemberTermRepository;
import com.example.umc10th.domain.member.repository.TermRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -35,6 +36,7 @@ public class MemberService {
private final TermRepository termRepository;
private final MemberFoodRepository memberFoodRepository;
private final MemberTermRepository memberTermRepository;
private final PasswordEncoder passwordEncoder;

public MemberResDTO.GetInfo getInfo(MemberReqDTO.GetInfo dto) {
Member member = memberRepository.findById(dto.id())
Expand All @@ -58,6 +60,7 @@ public MemberResDTO.SignUpInfo signUp(MemberReqDTO.SignUp dto) {
Member member = Member.builder()
.name(dto.name())
.email(dto.email())
.password(passwordEncoder.encode(dto.password()))
.phoneNumber(dto.phoneNumber())
.detailAddress(dto.detailAddress())
.gender(dto.gender() != null ? Gender.valueOf(dto.gender().toUpperCase()) : null)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example.umc10th.global.config;

import com.example.umc10th.global.security.CustomAccessDeniedHandler;
import com.example.umc10th.global.security.CustomAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private static final String[] PUBLIC_URIS = {
"/auth/**",
"/login",
"/logout",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**"
};

private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests -> requests
.requestMatchers(PUBLIC_URIS).permitAll()
.anyRequest().authenticated()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow error dispatch before enforcing authentication

anyRequest().authenticated()만 두면 Spring Boot의 /error 경로와 ERROR 디스패치까지 인증 대상으로 묶여서, 비로그인 요청에서 원래 404/500이어야 할 상황이 401로 덮일 수 있습니다(예: 존재하지 않는 URL 호출, permitAll 엔드포인트 내부 예외). 이러면 전역 예외 처리/에러 응답 학습이 왜곡되므로, /error 또는 dispatcherTypeMatchers(ERROR, FORWARD)permitAll로 열고 실제 보호가 필요한 API 경로 중심으로 인증을 거는 방식으로 수정해 보세요. 다음으로는 Spring Security의 DispatcherType 매칭과 Boot 기본 에러 처리 흐름을 같이 공부하는 것을 권장합니다.

Useful? React with 👍 / 👎.

)
.formLogin(form -> form
.usernameParameter("email")
.passwordParameter("password")
.defaultSuccessUrl("/swagger-ui/index.html", true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REST API 응답을 공통 JSON으로 맞추는 흐름이라면 로그인 성공 시 Swagger로 리다이렉트하는 전략과 API 클라이언트 계약이 섞일 수 있습니다. 학습 단계에서는 formLogin 세션 방식인지 JSON 로그인 API 방식인지 먼저 구분하고, 성공/실패 응답 정책을 한 흐름으로 정리하는 것을 권장합니다.

.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll()
)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.umc10th.global.security;

import com.example.umc10th.domain.member.entity.Member;
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.List;

@Getter
public class AuthMember implements UserDetails {

private final Long memberId;
private final String email;
private final String password;

public AuthMember(Member member) {
this.memberId = member.getId();
this.email = member.getEmail();
this.password = member.getPassword();
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}

@Override
public String getPassword() {
return password;
}

@Override
public String getUsername() {
return email;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.umc10th.global.security;

import com.example.umc10th.global.apiPayload.code.GeneralErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final SecurityResponseWriter securityResponseWriter;

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {
securityResponseWriter.write(response, GeneralErrorCode.FORBIDDEN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.umc10th.global.security;

import com.example.umc10th.global.apiPayload.code.GeneralErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final SecurityResponseWriter securityResponseWriter;

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
securityResponseWriter.write(response, GeneralErrorCode.UNAUTHORIZED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.umc10th.global.security;

import com.example.umc10th.domain.member.entity.Member;
import com.example.umc10th.domain.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findActiveByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("회원을 찾을 수 없습니다."));

return new AuthMember(member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.umc10th.global.security;

import com.example.umc10th.global.apiPayload.ApiResponse;
import com.example.umc10th.global.apiPayload.code.BaseErrorCode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
@RequiredArgsConstructor
public class SecurityResponseWriter {

private final ObjectMapper objectMapper;

public void write(HttpServletResponse response, BaseErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
objectMapper.writeValue(response.getWriter(), ApiResponse.onFailure(errorCode, null));
}
}
Loading