Spring

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

LIRI 2024. 6. 28. 23:07

버전

  • 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