Skip to content

Commit

Permalink
Spring Security 를 적용하여 '세션 로그인 방식'의 회원가입 및 로그인 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
haeunsong committed Nov 6, 2024
1 parent 7f4bcdf commit 47c0976
Show file tree
Hide file tree
Showing 12 changed files with 303 additions and 0 deletions.
1 change: 1 addition & 0 deletions 송하은/sugang-system/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-security'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.example.sugangsystem.controller;

import com.example.sugangsystem.dto.request.auth.LoginRequestDto;
import com.example.sugangsystem.dto.response.auth.MemberLoginResponseDto;
import com.example.sugangsystem.dto.response.auth.UserInfo;
import com.example.sugangsystem.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
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
@RequiredArgsConstructor
@RequestMapping("/api/members")
public class MemberController {

private final MemberService memberService;

/**
* 통합된 회원가입 및 사용자 정보 저장 API
* @param userInfo 사용자 정보 DTO
* @return 성공 시 회원 로그인 응답 DTO와 200 OK 반환
*/
@PostMapping("/join")
public ResponseEntity<MemberLoginResponseDto> registerOrUpdateUserInfo(@RequestBody UserInfo userInfo){
MemberLoginResponseDto response = memberService.saveUserInfo(userInfo);
// return new ResponseEntity<>(response,HttpStatus.OK); 와 같은 방식이다.
// 하지만 아래 방식으로 하는 것이 가독성과 유연성을 높인다. (필요한 경우 옵션도 여러개 추가 가능)
return ResponseEntity.status(HttpStatus.OK).body(response);
}

/**
* 로그인 API
* @param loginRequestDto 로그인 요청 DTO
* @return 성공 시 회원 로그인 응답 DTO와 200 OK 반환
*/
@PostMapping("/login")
public ResponseEntity<MemberLoginResponseDto> login(@RequestBody LoginRequestDto loginRequestDto, HttpServletRequest request) {
MemberLoginResponseDto response = memberService.login(loginRequestDto);

// 세션을 가져오거나 없으면 생성한다.
HttpSession session = request.getSession(true);

// Authentication 객체를 생성하여, 사용자가 인증된 상태임을 나타내는 정보를 담는다.
Authentication authentication = new UsernamePasswordAuthenticationToken(loginRequestDto.loginId(),null,null);

// SecurityContext 를 생성하고, 여기에 Authentication 객체를 설정한다.
// 이로써 사용자가 인증되었다는 정보를 Spring Security 가 알 수 있게 된다.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);

// 세션에 SecurityContext 저장
// 이 설정으로 인해 이후 요청에서도 세션을 통해 인증 상태가 유지된다.
SecurityContextHolder.setContext(context);
// "SPRING_SECURITY_CONTEXT"는 Spring Security에서 인증 정보를 세션에 저장할 때 사용하는 기본 키
session.setAttribute("SPRING_SECURITY_CONTEXT", context); // 현재 세션에 SecurityContext를 저장하여 사용자가 인증된 상태를 유지할 수 있도록 하는 코드

return ResponseEntity.ok(response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.sugangsystem.domain.auth;

import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;


@Entity
@Getter
@NoArgsConstructor

public class Member {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Enumerated(EnumType.STRING)
private Role role;

private String loginId;

private String pwd;

private String name;

@Builder
private Member(Role role, String loginId, String pwd, String name) {
this.role = role;
this.loginId = loginId;
this.pwd = pwd;
this.name = name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.sugangsystem.domain.auth;

public enum Role {
ROLE_ADMIN, ROLE_USER
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.sugangsystem.dto.request.auth;

public record JoinRequestDto(
String loginId,
String pwd,
String name
){
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.sugangsystem.dto.request.auth;

public record LoginRequestDto(
String loginId,
String pwd
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.sugangsystem.dto.response.auth;

import com.example.sugangsystem.domain.auth.Member;
import lombok.Builder;

@Builder
public record MemberLoginResponseDto(
Member findMember
) {
public static MemberLoginResponseDto from(Member member) {
return MemberLoginResponseDto.builder()
.findMember(member)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.sugangsystem.dto.response.auth;

import lombok.Getter;
import lombok.NoArgsConstructor;

// 모든 필드를 final 로 선언해서 불변 객체롤 만든다. => record 사용
public record UserInfo(
String loginId,
String pwd,
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.sugangsystem.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;


/*
AppConfig 라는 설정 클래스에 PasswordEncoder() 빈을 등록한다.
*/
@Configuration // 이 클래스가 스프링 설정 클래스임을 나타낸다. 스프링 부트가 애플리케이션을 시작할 때 이 클래스를 읽어, 정의된 빈들을 스프링 컨테이너에 등록한다.
public class AppConfig {

@Bean // 메서드의 반환값을 스프링 빈으로 등록하겠다. 싱글톤으로 관리되어 필요한 곳에서 자동으로 주입하여 사용할 수 있다.
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.example.sugangsystem.global.config;

import jakarta.servlet.http.HttpServletResponse;
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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Configuration // 스프링의 설정 클래스임을 나타낸다.
@EnableWebSecurity // 스프링 시큐리티 활성화, 보안 필터가 모든 HTTP 요청을 보호하도록 설정
public class SecurityConfig {

/*
SecurityFilterChain 을 빈으로 등록하고,
스프링 시큐리티의 보안 규칙을 정의한다.
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 비활성화. 대신 다른 인증방식(여기서는 세션 인증방식)을 사용할 수 있다.
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
.sessionManagement(sessionManagement -> // 세션을 필요할 때만 생성하도록 설정
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.formLogin(AbstractHttpConfigurer::disable) // 기본 폼 로그인 비활성화
.logout(logout -> logout // 로그아웃 관련 설정
.logoutUrl("/api/members/logout")
.logoutSuccessHandler(logoutSuccessHandler())
.invalidateHttpSession(true) // 로그아웃 시 현재 세션 무효화
.deleteCookies("JSESSIONID") // 쿠키 삭제
.permitAll() // 모든 사용자가 로그아웃 기능 사용할 수 있도록 설정
)
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/api/members/join","/api/members/login").permitAll() // 회원가입, 로그인 요청 모든 사용자에게 허용
.anyRequest().authenticated() // 그외 모든 요청은 인증 필요
);
return http.build();
}

// 로그아웃 성공시 실행되는 동작
@Bean
public LogoutSuccessHandler logoutSuccessHandler() {
return (request,response,authentication) -> {
response.setStatus(HttpServletResponse.SC_OK); // 로그아웃 성공시 HTTP 상태코드 200 OK 로 설정
response.setContentType("text/plain; charset=UTF-8");
response.getWriter().write("로그아웃 완료"); // 응답 본문에 "로그아웃 완료" 메시지 작성
response.getWriter().flush(); // 응답을 즉시 클라이언트로 전송한다.
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.sugangsystem.repository;

import com.example.sugangsystem.domain.auth.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByLoginId(String loginId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.example.sugangsystem.service;

import com.example.sugangsystem.domain.auth.Member;
import com.example.sugangsystem.domain.auth.Role;
import com.example.sugangsystem.dto.request.auth.LoginRequestDto;
import com.example.sugangsystem.dto.response.auth.MemberLoginResponseDto;
import com.example.sugangsystem.dto.response.auth.UserInfo;
import com.example.sugangsystem.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {

private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;

@Transactional
public MemberLoginResponseDto saveUserInfo(UserInfo userInfo) {
validateNotFoundLoginId(userInfo.loginId());

Member member = memberRepository.findByLoginId(userInfo.loginId())
.orElseGet(() -> createMember(userInfo));

return MemberLoginResponseDto.from(member);
}

private void validateNotFoundLoginId(String loginId) {
if (loginId == null || loginId.trim().isEmpty()) {
throw new IllegalStateException("로그인 ID가 필요합니다.");
}
}

private Member createMember(UserInfo userInfo) {
return memberRepository.save(
Member.builder()
.loginId(userInfo.loginId())
.pwd(passwordEncoder.encode(userInfo.pwd()))
.name(userInfo.name())
.role(Role.ROLE_USER)
.build()
);
}

// 로그인 요청 시 사용자가 입력한 loginId와 비밀번호가 서버에 저장된 정보와 일치하는지 확인하는 역할
public MemberLoginResponseDto login(LoginRequestDto loginRequestDto) {
Member member = memberRepository.findByLoginId(loginRequestDto.loginId())
.orElseThrow(() -> new IllegalArgumentException("회원 정보를 찾을 수 없습니다."));

// 유저가 입력한 pwd 와 db에 암호화되어 저장된 pwd 를 비교한다.
validationPassword(loginRequestDto.pwd(), member.getPwd());

return MemberLoginResponseDto.from(member);
}

private void validationPassword(String rawPassword, String encodedPassword) {
if(!passwordEncoder.matches(rawPassword, encodedPassword)) {
throw new IllegalStateException("비밀번호가 일치하지 않습니다.");
}
}
}

0 comments on commit 47c0976

Please sign in to comment.