인가 & 인증의 중요성
implementation 'org.springframework.boot:spring-boot-starter-security'
1) dependencies 안에 넣어주기
@RequiredArgsConstructor
@EnableWebSecurity
// WebSecurityConfigurerAdapter 을 상속받는 클래스에 이 어노테이션을 붙이면, SpringSecurityFilterChain이 자동 포함
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//WebSecurityConfigurerAdapter 을 상속 받아서 메소드 오버라이딩을 통해 보안 설정을 커스터마이징 가능
private final TokenService tokenService;
private final CustomUserDetailsService userDetailsService;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(HttpMethod.OPTIONS, "/**");
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//http 요청에 대한 보안을 설정, 페이지 권한 설정 / 로그인 페이지 설정 / 로그아웃 메소드 등에 대한 설정 작성
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
// 비밀번호를 db 그대로 저장 경우, db 해킹당하면 고객 정보 그대로 노출, 따라서 비밀번호 암호화 저장하는 함수를 통해 비밀번호 저장
//이를 빈으로 등록해 사용할 것
}
}
public static Member toEntity(
SignUpRequest req,
Role role,
PasswordEncoder encoder
) {
if(!req.password.equals(req.passwordcheck)){
throw new PasswordNotSameException();
}
return new Member
(
req.email,
encoder.encode(req.password),
req.username,
req.department,
req.contact ,
List.of(role),
new ProfileImage(
req.profileImage.
getOriginalFilename()
)
);
}
MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> , CustomMemberRepository{
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
SignService.java
private void validateSignUpInfo(SignUpRequest req) {
if(memberRepository.existsByEmail(req.getEmail()))
throw new MemberEmailAlreadyExistsException(req.getEmail());
}
private void validatePassword(SignInRequest req, Member member) {
if(!passwordEncoder.matches(req.getPassword(), member.getPassword())) {
throw new PasswordNotValidateException();
}
}
- 빈을 주입하는 방법
1) @Autowired 어노테이션 이용
2) 필드 주입(setter 주입)
3) 생성자 주입
(+) javax validation 어노테이션
SignController.java
public class SignController {
private final SignService signService;
Logger logger = LoggerFactory.getLogger(SignController.class);
@PostMapping("sign-up")
@ResponseStatus(HttpStatus.CREATED)
//@Valid 로 request에서 annotation 조건에 안 맞는 애 있는지 점검
public Response signUp(@Valid SignUpRequest req) {
signService.signUp(req);
return success();
}
CustomUserDetailService
UserDetailService 인터페이스는 데이터베이스에서 회원 정보를 가져오는 역할을 담당
loadUserByUsername 메소드가 존재하며, 회원 정보를 조회해 사용자 정보와 권한을 갖는 UserDetails 인터페이스 반환
얻은 Authentication 객체를 SecurityContext에 저장 (얘는 authenticated 되었다고 이 context에 저장시켜주는 것)
@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
/**
* 인증된 사용자의 정보를 CustomUserDetails로 반환
*/
private final MemberRepository memberRepository;
@Override
/**
* 유저의 Role, RoleType 확인
*/
public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
/**
* 사용자의 id 값으로 사용자 정보 조회
*/
Member member = memberRepository.findById(Long.valueOf(userId))
.orElseGet(() -> new Member(null, null, null, null, null ,List.of(), null));
return new CustomUserDetails( //3)
String.valueOf(member.getId()),
member.getRoles().stream().map(memberRole -> memberRole.getRole())
.map(role -> role.getRoleType())
.map(roleType -> roleType.toString())
//권한 등급은 String 인식, Enum 타입 RoleType을 String 변환
.map(SimpleGrantedAuthority::new).collect(Collectors.toSet())
//권한 등급을 GrantedAuthority 인터페이스로 받음
);
}
}
UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 오버라이딩
=> 로그인할 유저의 아이디를 넘겨준다.
3) : userdetail 구현하는 user 객체 반환, user 객체 생성 위해서 생성자로 파라미터 넘겨줌
CustomUserDetails.java
@Getter
@AllArgsConstructor
public class CustomUserDetails implements UserDetails {
/**
* 인증된 사용자의 정보와 권한 가짐
* userId, 권한 등급 메소드만 사용
* 나머지 메소드 호출 시 예외 발생
*/
private final String userId;
private final Set<GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getUsername() {
return userId;
}
@Override
public String getPassword() {
throw new UnsupportedOperationException();
}
@Override
public boolean isAccountNonExpired() {
throw new UnsupportedOperationException();
}
@Override
public boolean isAccountNonLocked() {
throw new UnsupportedOperationException();
}
@Override
public boolean isCredentialsNonExpired() {
throw new UnsupportedOperationException();
}
@Override
public boolean isEnabled() {
throw new UnsupportedOperationException();
}
}
SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* 로그인, 회원가입은 누구나
* 회원정보 가져오는 것은 누구나
* 멤버 삭제는 관리자 혹은 해당 멤버만
*/
http
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.authorizeRequests()
//.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()//added
.and()
.authorizeRequests()
.antMatchers(HttpMethod.POST, "/sign-in", "/sign-up", "/refresh-token").permitAll()
.anyRequest().hasAnyRole("ADMIN")//멤버의 역할이 관리자인 경우에는 모든 것을 허용
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()//인증되지 않은 사용자의 접근이 거부
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()//인증된 사용자가 권한 부족 등의 사유로 인해 접근이 거부
.addFilterBefore(new JwtAuthenticationFilter(tokenService, userDetailsService), UsernamePasswordAuthenticationFilter.class);
http.headers().frameOptions().sameOrigin();
}
/**
* 리프레쉬 토큰 만료 시 핸들러
인증되지 않은 사용자가 요청 시 작동 핸들러
*/
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
/**
* 토큰이 만료된 경우 예외처리
*/
response.setStatus(SC_UNAUTHORIZED);
response.sendRedirect("/exception/entry-point");
}
// 스프링 도달 전이라서 직접 상황에 맞게 응답 방식 작성 가능하나 response로 응답하도록 설정
}
/**
* 인증은 되었지만,
* 사용자가 접근 권한이 없을 시 작동 핸들러
*/
@NoArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();//
response.sendRedirect("/exception/access-denied");
}
}
/**
* 예외 사항 발생 시 "/exception/{예외}"로 리다이렉트
*/
private final TokenService tokenService;
@CrossOrigin(origins = "https://localhost:3000")
@GetMapping("/exception/entry-point")
public void entryPoint(@RequestHeader(value = "Authorization") String accessToken) {
/**
* 액세스 만료
*/
if (!tokenService.validateAccessToken(accessToken)) {
System.out.println("액세스가 만료");
throw new AccessExpiredException();
}
System.out.println("액세스 만료가 아니라 리프레시 에러야 이거는 ");
throw new AuthenticationEntryPointException();
}
@CrossOrigin(origins = "https://localhost:3000")
@GetMapping("/exception/access-expired")
public void accessExpired() {
throw new AccessExpiredException();
}
@CrossOrigin(origins = "https://localhost:3000")
@GetMapping("/exception/access-denied")
public void accessDenied() {
throw new AccessDeniedException();
}
}