Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#1 회원가입 기능 구현 시큐리티설정 작업 #8

Merged
merged 10 commits into from
Jan 14, 2025
4 changes: 4 additions & 0 deletions board/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,7 @@ out/
/gradlew
/gradle/wrapper/gradle-wrapper.properties
/gradle/wrapper/gradle-wrapper.jar


##DB정보##
/src/main/resources/application.properties
8 changes: 4 additions & 4 deletions board/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
//implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.3'
//testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'mysql:mysql-connector-java:8.0.32'
//runtimeOnly 'com.mysql:mysql-connector-j' 이거 랑 위에꺼랑 뭔차이?
}
implementation 'org.springframework.boot:spring-boot-starter-validation'
}

tasks.named('test') {
useJUnitPlatform()
Expand Down
57 changes: 57 additions & 0 deletions board/src/main/java/com/main/board/config/CustomUserDetails.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.main.board.config;

import com.main.board.member.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

public class CustomUserDetails implements UserDetails {

private final Member member;

public CustomUserDetails(Member member) {
this.member = member;
}
Comment on lines +13 to +17
Copy link
Collaborator

Choose a reason for hiding this comment

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

불변선언 좋습니다 👍


@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> auth = new ArrayList<GrantedAuthority>(); //ArrayList객체생성
auth.add(new SimpleGrantedAuthority("ROLE_USER"));//ROLE_USER권한을 부여
return auth; //권한리스트반환
}

@Override
public String getPassword() {
return member.getPassword();
}


@Override
public String getUsername() {
return member.getName();
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}

}
83 changes: 83 additions & 0 deletions board/src/main/java/com/main/board/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.main.board.config;

import com.main.board.util.PassWordEncoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.SessionManagementConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration // 스프링부트에게 이 클래스가 설정파일임을 알려줌 (빈등록)
@EnableWebSecurity // Spring Security 활성화 기본보안 필터체인이 적용된다
public class SecurityConfig {

private UserDetailService userDetailsService;

public SecurityConfig(UserDetailService userDetailsService) {
this.userDetailsService = userDetailsService;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //final HttpSecurity http 이점이있는가?
http
.csrf((auth) -> auth.disable()) // csrf 비활성화 (REST API등 비상태 통신에서는 CSRF토큰이 필요하지 않을수있다)
Copy link
Collaborator

Choose a reason for hiding this comment

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

CSRF 토큰은 어떤 역할을 할까요?
REST API등 비상태 통신에서는 이걸 disabled하는 이유는 어떤게 있을까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

CSRF는 공격자가 원하지않는 요청을 실행하는 취약점입니다
즉 사용자가 로그인된상태(인증된 세션)에서 공격자가 사용자 몰래 특정요청을 보내는 공격입니다
여기서 CSRF토큰의 역할은 "서버가 클라이언트에게 발급하는 일회성 난수값"으로
CSRF공격을 방지하기위해 요청에 함께전송되어집니다
그래서 서버는 해당 토큰이 유효한지 확인함으로 CSRF공격을 방지하는 역할을 합니다
여기서 REST API (Stateless)방식에서 CSRF를 비활성화 하는 이유는
REST API의 경우는 서버가 클라이언트의 상태를 유지하지않고
CSRF는 주로 세션기반 인증에서 발생하는 공격이기때문에
REST API는 주로 JWT나 OAuth토큰등 쿠키를 사용하지 않는 인증 방식을 사용하기에 dissable을 합니다

진행하고 있는 프로젝트가 만약 실제 클라이언트를 위한 서비스로 진행한다면
CSRF는 dissable하지 말아야할것이라 생각합니다

.httpBasic((auth) -> auth.disable()) // httpBasic 비활성화
Copy link
Collaborator

Choose a reason for hiding this comment

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

이 옵션을 끈 이유가 있을까요?
Spring Security의 httpBasic 옵션은 어떤 역할을 하나요?

아래 문서를 참고해서 답변해보세요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

httpBasic옵션은
웹사이트에 로그인 할때 아이디/비밀번호를 매번 입력한다고 가정하고
서버에 요청을 보낼때마다 아이디와 비밀번호가 따라가는 방식입니다
여기서문제는
아이디와 비밀번호에서 비밀번호가 그저 암호화가아닌 base64로 바꾼형태로 노출이된다는게 큰 문제입니다
에초에 요청에 아이디와 비밀번호 정보가 매번 담기는게 위험하기때문에
보안을 위해 Http Basic인증을 끄고
세션이나 JWT(토큰)방식으로 로그인한후 세션이나 토큰값만 보내는 방식을 사용하기위함입니다

.formLogin((auth) -> auth.disable()) // formLogin 비활성화
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/", "/member/signup", "/auth/login").permitAll() // "/" 경로는 모든 사용자에게 허용
.anyRequest().authenticated())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) //세션정책설정 (인증이 필요할때만 생성)
/*
**세션 고정 공격(Session Fixation Attack)**을 방지하기 위한 설정입니다.
세션 고정 공격은 공격자가 사용자의 세션 ID를 미리 설정하거나 가로채어 악용하는 공격입니다.

Spring Security에서는 세션 고정 공격을 방지하기 위해 새로운 세션을 생성하는 방식을 제공합니다.
newSession 설정은 인증 후 항상 새로운 세션을 생성합니다.
사용자가 로그인하거나 인증할 때 기존 세션을 폐기하고 새로운 세션을 생성합니다.
이로 인해 기존 세션 ID가 무효화되며, 세션 고정 공격을 방지합니다.
*/
.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::newSession)//세션고정공격방지
Copy link
Collaborator

Choose a reason for hiding this comment

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

세션 고정공격에 대한 방지는 기본값인 migrateSession 으로도 동작합니다.
newSession 옵션으로 설정하면 기존 세션을 무효화하는데요, 이 설정은 어떤 장/단점이 있을까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

우선 세션고정 공격이라는것은
로그인시 발급받은 세션ID가 로그인 전/후 모두 동일하게 사용되어 악의적인 사용자가 피해자의 세션을 하이제킹하여 정상적인 사용자로 위장하여 접근하는 행위입니다
(처음에는 사용자가 로그인하여 발급받은 세션아이디를 공격자가 탈취하여 사용하는 방식인줄알았으나)
세션고정 공격의 요점은 공격자가 먼저 사이트에 접속하여 세션아이디를 받은후
해당 세션아이디를 피해자에세 심는것이 요점입니다

1.migrateSession은 인증후에는 새로운 세션 ID를 발급하지만 기존 세션에 담긴 데이터는 유지되는것이 특징입니다 (ex:장바구니, 폼입력데이터)
세션ID가 인증후 변경되기때문에 세션고정공격에 방지가 되지만
데이터가 남아있을 위험이 존재합니다

  1. newSession의 경우는 인증후 완전히 새로운 세션을 생성하고 기존 데이터를 폐기합니다 즉 세션ID뿐만 아니라 세션자체가 새롭게 초기화됩니다
    그러면 보안성이 높아지는데 단점으로는 데이터가초기화되기때문에 사용자가 불편함을 느낄가능성이있습니다
    이런경우는 보통 금융권이나 보안성이 높은 요구사항인 경우에 사용해야합니다

현재 프로젝트는 기본적인 게시판 즉 폼데이터를 사용할것이라 예측되기때문에
newSesion보다는 migrateSession이 조금더 적합해 보인다고생각합니다
예를들어 글쓰기 작성중에 내용을 저장해야할수도 있을 가능성이 있기때문입니다

Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 내용 [newSesion보다는 migrateSession이 조금더 적합] 이 아직 적용안된것으로 보입니다. 적용해주세요~

.maximumSessions(1) // 동시세션수 제한 (하나의 사용자계정이 유지할수있는 세션의 수를 제한)
Copy link
Collaborator

Choose a reason for hiding this comment

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

동시세션수를 제한할 필요가 없다면, 해당 설정은 뺴는게 좋습니다.
유저가 여러 기기로 접속하는것도 막아버리거든요

);

return http.build();
}

// 로그인시에 Spring Security의 인증 진입점이다 클라이언트가 제공한 인증정보를 받아 AuthenticationManager를 통해 인증을 위임한다, Bean설정 필수
// LoginController에서 사용
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

/*
실제인증을 처리하는 부분인 Provider이다
DaoAuthenticationProvider는 UserDetailsService를 통해 사용자 정보를 가져오고 비밀번호를 확인한다

*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();

provider.setUserDetailsService(userDetailsService); // 사용자 정보를 로드할 서비스
provider.setPasswordEncoder(passwordEncoder()); // 비밀번호 암호화 확인

return provider;
}


//해당 방식으로 스프링 시큐리티가 CustomUserDetails 객체에서 반환된 비밀번호와 로그인 요청에서 받은 비밀번호를 비교.
@Bean
public PasswordEncoder passwordEncoder() {
return new PassWordEncoder();
}

}
24 changes: 24 additions & 0 deletions board/src/main/java/com/main/board/config/UserDetailService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.main.board.config;

import com.main.board.member.Member;
import com.main.board.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

// Spring Security는 해당 객체를 기반으로 인증을 처리한다
@Service
@RequiredArgsConstructor
public class UserDetailService implements UserDetailsService {

private final MemberRepository memberRepository;

//로그인시 유저를 찾고 유저가 있으면 CustomUserDetails를 반환
@Override
public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
Member member = memberRepository.findMemberById(userId)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 사용자가 없습니다."));
return new CustomUserDetails(member);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

깔끔하게 잘 구현하셨네요 👍

11 changes: 11 additions & 0 deletions board/src/main/java/com/main/board/login/DTO/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.main.board.login.DTO;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class LoginRequest {
private String username;
private String rawPassword;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.main.board.login.controller;

import com.main.board.login.DTO.LoginRequest;
import com.main.board.login.service.LoginService;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController //JSON을 받기위한 어노테이션
@RequestMapping("/auth")
public class LoginController {


private final LoginService loginService;

public LoginController(LoginService loginService) {
this.loginService = loginService;
}

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
loginService.login(loginRequest);
return ResponseEntity.ok("Login successful");
}

@PostMapping("/logout")
public ResponseEntity<?> logout(HttpSession session) {
session.invalidate(); //로그아웃시에 세션을 무효화하여 저장된 모든 데이터를 삭제한다 "SPRING_SECURITY_CONTEXT"해당 키 삭제
SecurityContextHolder.clearContext(); //SecurityContextHolder의 인증정보를 삭제한다
return ResponseEntity.ok().build(); // build()는 바디없이 빈 응답을 생성한다
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

로그아웃또한 직접적으로 구현하지 않아도 됩니다.
이런 요청들은 모두 Spring Security의 설정을 통해 가능합니다. 시큐리티 설정에서 logout URL을 지정할 수 있으니, 설정을 찾아 적용해보세요.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.main.board.login.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/*
@RestControllerAdvice 와 @ControllerAdvice 는 예외처리를 전역적으로 처리하기 위한 어노테이션 (spring 3.2부터 지원 Rest는 4.3부터 지원)
두개의 차이는 Rest즉 @ResponseBody가 유무 차이로 응답이 Json으로 내려주는지 아닌지의 차이이다
@ResponseBody는 메소드의 반환값을 Http 응답 바디에 직접 넣어주겠다는 의미이다
*/
@RestControllerAdvice(basePackages = "com.main.board.login.controller")
public class LoginExceptionHandler {

/*
@ExceptionHandler 어노테이션은 특정 예외가 발생했을 때 메소드가 처리하도록 하는 어노테이션이다
여기서는 BadCredentialsException 예외가 발생했을 때 handleBadCredentialsException 메소드가 처리하도록 한다
ProblemDetail은 Spring(6부터)에서 제공하는 클래스로 예외처리시 상태코드와 상세정보를 담아서 반환할 수 있다
기존방식으로 처리하게되면 ResponseEntity<Map<String, Object>> 형식으로 직접 관리를 해야하지만 ProblemDetail을 사용하면 편리하게 처리할 수 있다
Copy link
Collaborator

Choose a reason for hiding this comment

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

첨언을 하자면 Map으로 Response를 관리하는것은 좋지 않은 코드작성 방식입니다.
Map이 아닌 DTO로 반환해야 API 스펙이 명시적으로 변하며, 동적으로 스펙이 변경되지 않습니다.

즉, ResponseEntity<DTO> 의 형태로 리턴해주는것이 더 적절합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

ProblemDetail을 사용해도 됩니다~ Map만 피해주세요.

*/

//로그인 실패 (비밀번호 불일치) 예외처리
@ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentialsException(BadCredentialsException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다");
}

//로그인 실패 (아이디 불일치) 예외처리
@ExceptionHandler(UsernameNotFoundException.class)
public ProblemDetail handleUsernameNotFoundException(UsernameNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "아이디를 찾을수없습니다");
}


}
40 changes: 40 additions & 0 deletions board/src/main/java/com/main/board/login/service/LoginService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.main.board.login.service;

import com.main.board.login.DTO.LoginRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LoginService {

private final AuthenticationManager authenticationManager;

public void login(LoginRequest loginRequest) {
/*
인증 요청
토큰이라고 해서 토큰방식을 사용하는것이 아니고
Spring Security에서 토큰 기반의 인증을 수행하는 객체이다
Spring Secutiry 내부 에서 사용자 인증 정보를 담기위한 객체이다
*/
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getRawPassword()); //토큰생성
/*
1. authenticationManager가 Provider에게 인증을 위임
2. config에 설정한 내용대로 UserDetailsService를 통해 유저정보를 가져온다
3. 입력된비밀번호와 가져온정보의 비밀번호를 비교하여 인증에 성공하면 Authentication 객체를 생성하여 리턴
*/
Authentication authentication = authenticationManager.authenticate(token); // AuthenticationManager를 통해 인증을 시도한다
// 세션에 인증 정보 저장
/*
SecurityContextHolder는 SpringSecurity의 인증 정보를 저장하고 조회하는 컨텍스트
1. 인증이 성공하면 Authentication 객체를 SecurityContextHolder에 저장
2. 이후 클라인언트의 요청은 사용자 인증상태를 유지하게끔 한다
3. SPRING_SECURITY_CONTEXT는 SpringSecurity에서 사용하는 세션의 기본키이다
*/
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
17 changes: 17 additions & 0 deletions board/src/main/java/com/main/board/member/DTO/SignUpResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.main.board.member.DTO;

import com.main.board.member.Member;

import java.time.LocalDate;

public class SignUpResponse {
private String memberId;
private String name;
private LocalDate createDate;

public SignUpResponse(Member member) {
this.memberId = member.getMemberId();
this.name = member.getName();
this.createDate = member.getCreateDate();
}
}
52 changes: 52 additions & 0 deletions board/src/main/java/com/main/board/member/DTO/SignupRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.main.board.member.DTO;

import com.main.board.member.Member;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;


@Getter
@NoArgsConstructor
public class SignupRequest {

@NotBlank
private String userId;
@NotBlank
private String rawPassword;
@NotBlank
private String name;
@NotNull
private LocalDate createDate;



public SignupRequest(String userId, String password, String name, LocalDate createDate) {
this.userId = validateId(userId);
this.rawPassword = validatePaswd(password);
this.name = name;
this.createDate = createDate;
}

public String validateId(String userId) {
if (userId.length() < 5 || userId.length() > 20) {
throw new IllegalArgumentException("아이디는 5자 이상 20자 이하로 입력해주세요.");
}
return userId;
}

public String validatePaswd(String password) {
if (password.length() < 8 || password.length() > 20) {
throw new IllegalArgumentException("비밀번호는 8자 이상 20자 이하로 입력해주세요.");
}
return password;
}

public Member toMemberEntity(String pwd) {
return new Member(this.userId, pwd, this.name, this.createDate);
}

}
Loading
Loading