이전 과정을 거쳤다면 다음으로 accessDeniedHandler와 authenticationEntryPoint를 커스터마이징하는 것이다. 기본적으로 제공되지만 인증, 인가가 실패했을 때 서버와 클라이언트에서 더 명확하게 판단할 필요가 있었기에 직접 작성해야했다.
먼저 CustomAccessDeniedHandler이다.
CustomAccessDeniedHandler.java
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.info("접근이 막힘 ==> 경로 redirect시도.");
response.sendRedirect("/sign/exception"); //sendRedirect를 통해 리다이렉트.
}
}
이 핸들러의 역할은 리소스에 접근할 때, 접근할 권한이 없는 경우를 처리한다. 쉽게 말해 관리자만 접근할 수 있는 것에 일반 유저가 접근한 경우를 생각하면 된다.
그 다음, 인증이 실패한 경우를 처리하는 클래스는 다음과 같다.
CustomAuthenticationEntryPoint.java
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
log.info("인증 실패 ==> response.sendError 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패했습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
인증이 실패했다는 것은 SecurityContextHolder에 등록되지 않은 유저가 접근했다는 뜻으로, 유효하지 않은 토큰을 가지고 있거나 토큰이 없는 사람인 경우이다.
HttpServletResponse를 이용해 http response를 직접 수정해 리턴해준다. 401은 httpstatus의 conflict 상태코드를 의미한다.
여기까지 작성하면 설정은 다 끝났다. 이제 회원가입 로직을 작성해보자.
나는 Sign이라는 별도의 디렉토리를 만들어서 작성했다.
SignServiceImpl.java의 signup메서드
@Override
public ResponseSignUpDto signUp(RequestSignUpDto requestSignUpDto) {
log.info("SignService signup ==> 회원가입 정보 확인");
User user;
//String userId = requestSignUpDto.getUserId();
String username = requestSignUpDto.getUsername();
String password = requestSignUpDto.getPassword();
String mail = requestSignUpDto.getMail();
String role = requestSignUpDto.getRole();
if(userRepository.getByUsername(username).isPresent()){ //아이디 중복 확인
throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.CONFLICT, "아이디 중복");
}
if(userRepository.getByMail(mail).isPresent()){ //메일 중복 확인
throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.CONFLICT, "메일 중복");
}
if(role.equalsIgnoreCase("admin")){ //만약 관리자로 회원가입했다면
user = User.builder()
.username(username)
.password(passwordEncoder.encode(password))
.mail(mail)
.roles(Collections.singletonList("ROLE_ADMIN"))
.build();
}
else{ //대부분 일반유저권한인 USER로 로그인될 것.
user = User.builder()
.username(username)
.password(passwordEncoder.encode(password)) //비밀번호는 암호화해서 db에 저장.
.mail(mail)
.roles(Collections.singletonList("ROLE_USER"))
.build();
}
User savedUser = userRepository.save(user);
ResponseSignUpDto responseSignUpDto = new ResponseSignInDto();
log.info("SignService signup ==> user 저장");
if(!savedUser.getUsername().isEmpty()){
log.info("signup 완료");
setSuccess(responseSignUpDto);
}
else{
log.info("signup 실패");
setFailed(responseSignUpDto);
}
return responseSignUpDto;
}
회원가입하는 로직이다. 우리는 메일과 id가 중복되지 않도록 하기로 했기에 아이디, 메일 중복을 먼저 체크해준다. 이후 회원가입하는 사람의 권한 정보에 따라 관리자인지, 유저인지 구분해서 저장한다.
여기서 passwordEncoder는 설정파일에 다음과 같이 빈으로 주입했다.
SecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
그 다음, 로그인하는 메서드이다.
@Override
@Transactional
public ResponseSignInDto signIn(RequestSignInDto requestSignInDto) {
log.info("SignService signin ==> 회원 인증 확인 시작");
String username = requestSignInDto.getUsername();
String password = requestSignInDto.getPassword();
User user = userRepository.getByUsername(username)
.orElseThrow(() -> new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.NOT_FOUND, "잘못된 id"));
log.info("회원 id : " + username);
if(!passwordEncoder.matches(password, user.getPassword())){
throw new CustomException(ConstantsClass.ExceptionClass.SIGN, HttpStatus.BAD_REQUEST, "비밀번호 불일치");
}
log.info("비밀번호 일치");
String accessToken = jwtTokenProvider.createToken(user.getUsername(), user.getRoles());
String refreshToken = jwtTokenProvider.createRefreshToken(user.getUsername());
ResponseSignInDto responseSignInDto = ResponseSignInDto.builder()
.token(accessToken)
.refreshToken(refreshToken)
.user_id(user.getId())
.username(username)
.build();
log.info("ResponseSignInDto 생성");
setSuccess(responseSignInDto);
return responseSignInDto;
}
중간중간에 refreshToken 부분이 보일 것인데, 이는 나중에 포스팅할 토큰의 단점을 해결하기 위해 사용한 방법이다. 지금은 신경쓰지 않아도 무방하다.
로그인을 위해 들어온 아이디를 검색해 실제로 db에 회원가입한 유저인지 검색한 후, 비밀번호가 매치되는지 체크한다.
만약 둘 다 성공하면, 토큰을 생성해서 사용자에게 넘겨준다. 이제 클라이언트가 api요청할 때 토큰을 헤더에 담아 전송하면 , 이전에 작성했던 필터를 통해 인증, 인가과정을 거치게 되는 것이다.
서비스에서 사용하는 dto들은 다음과 같다.
RequestSignInDto.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class RequestSignInDto {
@NotBlank
String username;
@NotBlank
String password;
}
RequestSignUpDto.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class RequestSignUpDto {
@NotBlank(message = "이름을 입력해주세요")
String username;
@NotBlank
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}",
message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8 - 20자의 비밀번호여야 합니다.")
String password;
@NotBlank
String role;
@NotBlank
@Email(message = "이메일을 입력해주세요")
String mail;
}
ResponseSignInDto.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ResponseSignInDto extends ResponseSignUpDto{
private String token;
private String refreshToken;
private Long user_id;
private String username;
@Builder
public ResponseSignInDto(boolean success, int code, String msg, String token, String refreshToken, Long user_id, String username){
super(success, code, msg);
this.token = token;
this.refreshToken = refreshToken;
this.user_id = user_id;
this.username = username;
}
}
ResponseSignUpDto.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ResponseSignUpDto {
private boolean success;
private int code;
private String msg;
}
이제 이 로직들을 이용해 컨트롤러를 작성해보자.
SignController.java
@PostMapping("/sign-in")
@Operation(summary = "로그인 시도", description = "생성되어있는 계정으로 로그인합니다.", responses = {
@ApiResponse(responseCode = "200", description = "로그인 성공", content = {
@Content(mediaType = "application/json", schema =
@Schema(implementation = ResponseSignInDto.class))
}),
@ApiResponse(responseCode = "400", description = "로그인 실패")
})
public ResponseEntity<ResponseSignInDto> signIn(@RequestBody RequestSignInDto requestSignInDto) throws CustomException {
log.info("SignController signIn ==> 로그인 시도 id : " + requestSignInDto.getUsername());
ResponseSignInDto responseSignInDto = signService.signIn(requestSignInDto);
if(responseSignInDto.getCode() == 0){
log.info("로그인 성공 ==> token : " + responseSignInDto.getToken());
}
return ResponseEntity.status(HttpStatus.OK).body(responseSignInDto);
}
@PostMapping("/sign-up")
@Operation(summary = "회원가입", description = "회원가입을 시도합니다.", responses = {
@ApiResponse(responseCode = "200", description = "회원가입 성공", content = {
@Content(mediaType = "application/json", schema =
@Schema(implementation = ResponseSignUpDto.class))
}),
@ApiResponse(responseCode = "400", description = "회원가입 실패")
})
public ResponseEntity<ResponseSignUpDto> signUp(@RequestBody RequestSignUpDto requestSignUpDto){
log.info("SignController signUp ==> 회원가입 시도 id : " + requestSignUpDto.getUsername() + " mail : " + requestSignUpDto.getMail() +
" role : " + requestSignUpDto.getRole());
ResponseSignUpDto responseSignUpDto = signService.signUp(requestSignUpDto);
log.info("회원가입 완료");
return ResponseEntity.status(HttpStatus.OK).body(responseSignUpDto);
}
어노테이션은 대부분 swagger를 위한 설정들이라 크게 신경쓸 필요는 없다. post, get만 보면 되고, 안에서 signService의 메서드를 이용해 반환값을 받아 리턴하는 간단한 구조다.
이제 swagger를 열어서 한 번 테스트해보도록 한다.

서버를 동작시킨 후 http://localhost:8080/swagger-ui/index.html#/로 접속하면 다음과 같이 뜬다. 회원가입을 먼저 해본다.

메일은 실제로 있는 메일을 입력했다고 가정한다. 실제로는 그걸 검증하는 코드도 있지만, 복잡해지는 것 같아 관련없는 부분을 제외했다.

실행한 결과 로그가 정상적으로 출력되었다. DB에 저장되었는지도 확인해본다.

testuser1 사용자가 DB에도 성공적으로 insert된 것을 볼 수 있다. 비밀번호부분은 암호화되어서 저렇게 보이는 것이다.
이제 회원가입한 내용으로 로그인을 시도해서, 토큰을 받아보자.

로그인 api를 실행하려면 body에 이렇게 입력하면 된다. 회원가입할 때 입력한 정보대로 입력해야한다.

로그인 결과, token이 생성되어 성공적으로 반환된 모습이다. 이제 이 토큰을 가지고 게시글 생성을 시도해보자.

게시글 생성을 하려면 인증, 인가를 거쳐야한다. securityConfig에서 허용하지 않은 url이기 때문이다. 이는 게시글 작성을 하려면 반드시 로그인한 유저만 하도록 하려는 의도이다.
먼저 토큰없이 게시글을 생성해보자.

인증이 실패했다는 메시지가 왔다. CustomAuthenticationEntryPoint가 동작한 것이다. 즉 토큰이 없거나 유효하지 않은, 인증되지 않은 유저라는 뜻이다.
인증받기 위해서는 로그인할 때 받은 그 토큰이 필요하다.

swagger설정을 통해 헤더에 X-AUTH-TOKEN필드에 토큰을 넣어 보낼 수 있도록 했다. 토큰을 복사 붙여넣기로 게시글 데이터와 같이 보내보자.

이번엔 성공했다! 게시글이 성공적으로 작성되었다.

DB에도 token test1 게시글이 성공적으로 작성된 모습이다.
토큰을 통한 인증, 인가는 이런 프로세스를 통해 이루어지도록 했다. 하지만 문제라면, 세션과 다르게 http 메시지 헤더에 토큰을 넣고 보낸다는 점에서 탈취 당했을 때 위험하다는 단점이 있다. 이제 이 단점을 어느정도 극복한 과정은 다음 글에 작성하려 한다.
'Backend > spring security' 카테고리의 다른 글
spring security : refresh 토큰 구현(redis 활용) (0) | 2023.10.24 |
---|---|
spring security : jwt를 이용한 로그인 구현[1] (0) | 2023.10.14 |