[SpringBoot] 스프링부트 프로젝트 시작하기 - 5. Security 설정하고 로그인 확인하기
버전
- SpringBoot 3.3.0
- Java 17
- JWT 0.12.5
목표
- SpringSecurity + JWT 적용하기
- 토큰을 통해 인가된 사용자 구분하기
- 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이라는 테이블이 생성된 것을 확인할 수 있다.
회원가입을 진행하면 정상적으로 진행되며
데이터베이스에도 정상적으로 권한이 들어간 것을 확인할 수 있다. 더불어 암호화 비밀번호까지 암호화하여 데이터베이스에 저장되었음을 확인하자.
그리고 가입 정보로 로그인을 하게 되면
이렇게 토큰을 반환하는것을 확인한다.
참고
본 포스팅은 아래 강의를 참고하여 작성하였습니다.
[지금 무료] [초급] 찍어먹자! 코틀린과 Spring Security + JWT로 회원가입 만들기 강의 | 김대디 - 인프
김대디 | Spring Security와 JWT 실습을 통해 권한 관리를 쉽고 간단하게 찍어먹어 보세요., 떠오르는 백엔드 강자 코프링, 회원가입 & 권한 관리 실습으로 확실하게! Kotlin + Spring Boot찍먹하며 배우는
www.inflearn.com