[SpringBoot] 스프링부트 프로젝트 시작하기 - 5. Security 설정하고 로그인 확인하기

2024. 6. 28. 23:07·Spring

버전

  • SpringBoot 3.3.0
  • Java 17
  • JWT 0.12.5

목표

  1. SpringSecurity + JWT 적용하기
  2. 토큰을 통해 인가된 사용자 구분하기
  3. Deprecated 된 함수 사용하지 않기

구현

SecuriryConfig

package com.study.springStarter.common.authority;

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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement
                        -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(request -> request
                        .requestMatchers("/api/member/signup","/api/member/login").anonymous()
                        .requestMatchers("/api/member/**").hasRole("MEMBER")
                        .anyRequest().permitAll()
                )
                .addFilterBefore(
                        new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class
                );
        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

이제 권한관리를 해줄 `SecuriryConfig`를 작성한다.

`.httpBasic(AbstractHttpConfigurer::disable)` : http 기본 인증 비활성화
`.csrf(AbstractHttpConfigurer::disable)` : csrf 비활성화
`sessionManagement()` : JWT 토큰을 사용하므로 Session 비활성화
`.authorizeHttpRequests(...)` : 이 부분에서 권한 관리를 해줌
`.anonymous()` : 인증되지 않은 사용자 허용
`.hasRole("MEMBER")` : "MEMBER" 멤버 권한을 가진 사용자 허용
`.anyRequest().permitAll()` : 그밖의 요청은 모두 허용

.addFilterBefore(
        new JwtAuthenticationFilter(jwtTokenProvider),
        UsernamePasswordAuthenticationFilter.class
)

앞에 있는 필터가 먼저 실행되고 앞 필터를 통과하면 뒤에 필터는 실행하지 않음.

마지막으로 암호화를 위해 passwordEncoder를 빈 등록.

 

Role

위에 "MEMBER"라는 권한을 주었기때문에 권한을 관리할 ENUM 클래스를 작성해준다.

package com.study.springStarter.common.status;

import lombok.Getter;

@Getter
public enum Role {
    MEMBER
}

이 권한을 이제 회원 객체와 연결을 시켜주어야한다.

`Member.java`

package com.study.springStarter.member.entity;

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

import java.time.LocalDate;
import java.util.List;

@Entity
@Table(name = "`member`")
@Getter
@NoArgsConstructor
public class Member {

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

    @Column(nullable = false, length = 30, unique = true, updatable = false)
    private String loginId;

    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false, length = 10)
    private String name;

    @Column(nullable = false)
    @Temporal(TemporalType.DATE)
    private LocalDate birthDate;

    @Column(nullable = false, length = 50)
    private String email;


    @OneToMany(fetch = FetchType.LAZY, mappedBy = "member")
    private List<MemberRole> memberRoles;

    @Builder
    private Member(Long id, String loginId, String password, String name, LocalDate birthDate, String email) {
        this.id = id;
        this.loginId = loginId;
        this.password = password;
        this.name = name;
        this.birthDate = birthDate;
        this.email = email;
        this.memberRoles = null;
    }
}

기존에 작성해둔 Member Entity에 Role을 조회할 수 있게 `@OneToMany`로 연결을 해 주었다.

 

`MemberRole`

package com.study.springStarter.member.entity;

import com.study.springStarter.common.status.Role;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
public class MemberRole {

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

    @Column(nullable = false, length = 30)
    @Enumerated(EnumType.STRING)
    private Role role;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(foreignKey = @ForeignKey(name = "id"))
    private Member member;

    @Builder
    private MemberRole(Long id, Role role, Member member) {
        this.id = id;
        this.role = role;
        this.member = member;
    }
}

`MemberRole`이라는 Entity를 추가하여 역시 Member와 연결시켜주었다.

 

Member가 수정되었으므로 회원가입 과정도 약간의 수정이 필요하다.

`MemberService`

private final BCryptPasswordEncoder passwordEncoder;

DB에 회원 정보 저장시 비밀번호를 암호화 하여 저장하기 위해 @Bean으로 등록했던 passwordEncoder를 선언해주고

/**
 * 회원 가입
 */
public String signUp(MemberRegisterRequest request) {
    if (memberRepository.findByLoginId(request.getLoginId()).isPresent()) {
        throw new InvalidInputException("loginId", "중복된 ID 입니다.");
    }

    Member member = request.toEntity(passwordEncoder.encode(request.getPassword()));

    memberRepository.save(member);
    MemberRole memberRole = MemberRole.builder()
            .role(Role.MEMBER)
            .member(member)
            .build();
    memberRoleRepository.save(memberRole);

    return "가입되었습니다.";
}

회원 가입 과정을 위처럼 수정해준다. Member Entity로 변환 시 암호화 한 값으로 변환해주고 멤버를 저장하며 memberRole도 함께 저장해주었다.

 

로그인 기능 구현

`LoginRequest`

package com.study.springStarter.member.controller.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class LoginRequest {

    @NotBlank
    @Pattern(regexp = "^[a-zA-Z0-9_]{5,20}",
            message = "영문, 숫자, _만을 사용한 5~20자리로 입력하세요")
    @Size(min = 5, max = 20,
            message = "5~20자 사이로 입력하세요.")
    private String loginId;
    @NotBlank
    @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@$%^&*])[a-zA-Z0-9!@$%^&*]{8,20}",
            message = "영문, 숫자, 특수문자(!@$%^&*)만 입력 가능합니다.")
    @Size(min = 5, max = 20,
            message = "8~20자 사이로 입력하세요.")
    private String password;
}

로그인 시 받을 Request먼저 작성해준다. 이전 포스팅에서 다뤘던 대로 Valid를 설정하여 아이디와 비밀번호를 입력받는다.

 

MemberService

private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;

2개의 변수를 추가해주고

/**
 * 로그인
 */
public TokenInfo login(LoginRequest request) {
    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(request.getLoginId(), request.getPassword());
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    return jwtTokenProvider.createToken(authentication);
}

로그인 함수를 만든다.
request로 부터 아이디와 비밀번호를 받아 AuthenticationToken을 발급받게 되고 그 토큰을 AuthenticationManagerBuilder를 통해 조금 이따 작성할 CustomUserDetailService의 유저를 찾는 과정을 거치게 된다. 유저를 찾아 문제가 발생하지 않는다면 그 인증 정보를 가지고 토큰을 발행해 반환하게 된다.

 

`MemberController`

@PostMapping("/login")
public BaseResponse<?> login(@RequestBody @Valid LoginRequest request) {
    return BaseResponse.ok(memberService.login(request));
}

컨트롤러에도 간단하게 로그인을 작성해주고 CustomUserDetailService를 작성하자

 

`CustomUserDetailService`

package com.study.springStarter.member.service;

import com.study.springStarter.common.authority.CustomUser;
import com.study.springStarter.member.entity.Member;
import com.study.springStarter.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByLoginId(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));

        return createUserDetails(member);
    }

    private UserDetails createUserDetails(Member member) {
        List<SimpleGrantedAuthority> authorities = member.getMemberRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRole()))
                .toList();
        return new CustomUser(member.getId(), member.getLoginId(), member.getPassword(), authorities);
    }
}

`UserDetailsService`를 상속받은 서비스를 정의한다. UserDetailsService를 상속받으면 loadByUsername이라는 함수를 오버라이드 해야한다. 함수 안에 username(loginId)로 회원을 찾는 기능을 구현하고 회원을 찾았다면 UserDetails 형태로 반환해주자.

그리고 이때 회원 정보를 못찾았을 경우 UsernameNotFoundException을 던져주지만 실제로는 `BadCredentialsException`가 던져진다.
GlobalExceptionHandler에 예외를 핸들링해준다.

@ExceptionHandler(BadCredentialsException.class)
public BaseResponse<?> badCredentialsException(BadCredentialsException ex) {
    Map<String, String> errors = new HashMap<>();
    String errorMessage = ex.getMessage();
    errors.put("로그인 실패", "입력 정보를 확인하세요.");

    return BaseResponse.error(HttpStatus.BAD_REQUEST, errors);
}

 

 

이제 서버를 실행시켜 구현한 기능이 제대로 돌아가는지 확인해보자. 편의상 데이터 베이스를 비우고 진행했다.

member_role이라는 테이블이 생성된 것을 확인할 수 있다.

회원가입을 진행하면 정상적으로 진행되며

데이터베이스에도 정상적으로 권한이 들어간 것을 확인할 수 있다. 더불어 암호화 비밀번호까지 암호화하여 데이터베이스에 저장되었음을 확인하자.

그리고 가입 정보로 로그인을 하게 되면

이렇게 토큰을 반환하는것을 확인한다.

 

 

참고

본 포스팅은 아래 강의를 참고하여 작성하였습니다.

https://inf.run/acEm

 

[지금 무료] [초급] 찍어먹자! 코틀린과 Spring Security + JWT로 회원가입 만들기 강의 | 김대디 - 인프

김대디 | Spring Security와 JWT 실습을 통해 권한 관리를 쉽고 간단하게 찍어먹어 보세요., 떠오르는 백엔드 강자 코프링, 회원가입 & 권한 관리 실습으로 확실하게!  Kotlin + Spring Boot찍먹하며 배우는

www.inflearn.com

 

728x90
저작자표시 비영리 변경금지 (새창열림)
'Spring' 카테고리의 다른 글
  • [SpringBoot] 스프링부트 프로젝트 시작하기 - 6. 토큰 이용하여 인가된 사용자 걸러내기
  • [SpringBoot] 스프링부트 프로젝트 시작하기 - 4. JWT 토큰 생성하기
  • [SpringBoot] 스프링부트 프로젝트 시작하기 - 3. Validation 이용하여 예외처리하기
  • [SpringBoot] 스프링부트 프로젝트 시작하기 - 2. JPA 활용하여 회원가입 만들기
LIRI
LIRI
  • LIRI
    기록
    LIRI
  • 전체
    오늘
    어제
    • 분류 전체보기 (74)
      • 블로그 꾸미기 (0)
      • Spring (6)
      • 바이브코딩 (1)
      • React (3)
      • CS (0)
      • 알고리즘 (57)
        • 개념 (2)
        • 문제풀이 (54)
      • Java (1)
      • DB (1)
      • log (4)
        • SSAFY (3)
        • 궁금 (1)
  • 블로그 메뉴

    • 홈
    • 방명록
  • 공지사항

  • 인기 글

  • 태그

    dfs
    pccp모의고사
    커서ai
    BFS
    리액트
    ssafy 합격 후기
    LV2
    알고리즘
    dp
    BOJ
    lv3
    Spring
    LIS
    비트마스킹
    너비우선탐색
    Java
    백준
    SSAFY
    Springsecurity
    그리디
    프로그래머스
    알고리즘 문제풀이
    싸피
    springboot
    최장증가부분수열
    바이브코딩
    JWT
    느좋코딩
    Security
    SSAFY 9기
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
LIRI
[SpringBoot] 스프링부트 프로젝트 시작하기 - 5. Security 설정하고 로그인 확인하기
상단으로

티스토리툴바