이전 글
[토이 프로젝트] spring boot 로그인 기능 만들기-2 (스프링 시큐리티 적용)
이전 프로젝트 [토이 프로젝트] 회원가입 기능 만들기-1사실 아직 만들고 싶은 프로젝트는 생각안해봤다.다만 프로젝트를 해봐야 실력이 정말 늘 것 같아서 일단 대중적이고 레퍼런스 자료가
devhan.tistory.com
이전 게시글에서 스프링 시큐리티를 통과하는 단순한 로그인 기능을 구현했다. 문제점은 DB를 조회해서 유효성 검사하는 기능까지는 다 됐지만 로그인 이후에 인증이 필요한 페이지에 접속하면 접속이 안되는 문제가 있었다.
왜냐면 스프링 시큐리티에 이 사용자는 인증된 사용자임을 모르기 때문이다. 즉, 인증 상태가 유지되지 않는다.
이번에는 http header에 Authorization 인증 정보를 추가해서 인증 유지를 시켜보려한다. http header에 인증 정보를 추가하는 방식에는 아래 세 가지 기술들이 있다.
- Bearer Token
- JWT
- OAuth 2.0
이 세 개 중에 제일 쉽고 간편한 1번 API Key 방식을 사용해서 토큰 인증 방식을 구현하겠다. 아, 그리고 비밀번호를 언제까지나 평문으로 저장할 수 없으니 비밀번호도 암호화해서 저장하는 방식을 추가로 또 적용해주자.
API Key
- 구현 난이도가 쉬움
- 서버에서 임의의 토큰을 생성후 클라이언트가 요청 시 헤더에 추가하는 방식
제일 먼저 해줄 작업은 PasswordEncoder를 AuthService에 의존성을 주입해준다. 그래서 SecurityConfig에서 PasswordEncode의 Bean을 추가해주고 AuthSerivce에서 불러오면 된다.
PasswordEncoder
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// ... 생략
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Bean 등록을 해줬으니 이제 AuthService에서 주입해주면 된다. 그리고 token을 저장할 저장소도 같이 만들어준다.
@Service
@RequiredArgsConstructor
public class AuthService {
private final PasswordEncoder passwordEncoder;
private final Map<String, String> tokenStore = new HashMap<>(); // 토큰 저장소
//...
}
그리고 기존에 있던 AuthService에 있던 login()을 수정해주고 토큰을 발급하는 함수와 비밀번호를 체크하는 함수를 따로 만들어줬다.
아, 그전에 LoginResponseDto에서 token 필드를 추가해준다.
로그인 시 토큰 발급
LoginResponseDto
@Getter
@AllArgsConstructor
@Builder
public class LoginResponseDto {
private Long id;
private String userId;
private String nickname;
private String email;
private String job;
private int age;
private String token;
}
그리고 AuthService를 수정한다.
AuthService
/**
* 로그인
* @param loginRequestDto
* @return
*/
public LoginResponseDto login(LoginRequestDto loginRequestDto) {
Member findMember = authRepository.findByUserId(loginRequestDto.getUserId())
.orElseThrow(() -> new IllegalArgumentException("회원 정보가 없습니다."));
boolean passwordFlag = validatePassword(loginRequestDto.getPassword(), findMember.getPassword());
String token = null;
if(passwordFlag) {
token = generateToken(findMember);
} else {
throw new IllegalArgumentException("비밀번호를 확인하세요.");
}
return LoginResponseDto.builder()
.id(findMember.getId())
.userId(findMember.getUserId())
.email(findMember.getEmail())
.job(findMember.getJob())
.age(findMember.getAge())
.nickname(findMember.getNickname())
.token(token)
.build();
}
/**
* token 생성 후 반환
* @param member
* @return token
*/
public String generateToken(Member member) {
String token = UUID.randomUUID().toString(); // 간단한 토큰 생성
tokenStore.put(token, member.getUserId());
return token;
}
/**
* 비밀번호 검사
* @param inputPassword
* @param memberPassword
* @return boolean
*/
public boolean validatePassword(String inputPassword, String memberPassword) {
return passwordEncoder.matches(inputPassword, memberPassword);
}
로직은 간단하다.
- DB에 사용자 이름으로 member를 검색한다.
- 사용자가 없으면 Exception을 던진다.
- 사용자가 있으면 입력된 패스워드와 DB에 저장된 패스워드가 같은지 유효성 검사를 한다.
- 패스워드가 맞으면 토큰을 UUID로 발급하고 틀리면 예외를 발생시킨다.
그리고 AuthController도 수정해준다.
AuthController
@PostMapping("login")
public ResponseEntity<?> login(@RequestBody LoginRequestDto loginRequestDto) {
LoginResponseDto loginResponseDto = authService.login(loginRequestDto);
if(loginResponseDto.getToken() == null) {
ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("아이디와 비밀번호를 확인해주세요.");
}
return ResponseEntity.ok(loginResponseDto);
}
token 값이 null이면 예외를 터트리고, 아니면 loginResponseDto를 반환한다.
그리고 추가적인 작업! 비밀번호를 로그인 시 비밀번호 암호화 작업을해서 회원가입할 때도 이 부분을 추가해줘야한다.
회원가입 시 패스워드 암호화
@Transactional
public boolean signup(SignupRequestDto signupRequestDto) {
String encodedPassword = passwordEncoder.encode(signupRequestDto.getPassword());
authRepository.save(signupRequestDto.toEntity(encodedPassword));
return true;
}
signupRequestDto에서 toEntity() 메서드를 하나 더 만들어준다.
public Member toEntity(String encodedPassword) {
return Member.builder()
.userId(this.userId)
.password(encodedPassword)
.nickname(this.nickname)
.email(this.email)
.job(this.job)
.age(this.age)
.build();
}
이러면 완성! 일단 회원가입 시 비밀번호가 제대로 암호화되는지 확인해보자.
그런데 일단 회원가입 페이지가 인증이 필요한 페이지라 제외해줬다. 그리고 테스트를 위해 인증이 필요한 화면 테스트용으로 하나 만들었다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/", "/index.html", "/member/*", "/api/auth/*").permitAll()
.anyRequest().authenticated())
.exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(((request, response, authException)
-> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))); // 401 응답 반환
return http.build();
}
그리고 index.html에 테스트용 화면으로 이동할 수 있는 a 태그를 추가한다.
<body>
<h2>메인입니다.</h2>
<a href="member/login.html">로그인 이동</a>
<a href="member/signup.html">회원가입 이동</a>
<a href="test/test.html">인증 필요 화면</a>
</body>
이러고 서버를 재시작하면 회원가입 페이지도 인증없이 접근할 수 있다.
그리고 회원가입 후 DB를 조회해보면 평문이 아닌 암호문으로 저장된 패스워드를 확인할 수 있다.
인증이 필요한 request 요청 시 Authorization 헤더 확인
인증이 필요한 request 요청 시 토큰을 검사하기 위해 토큰 관련 필터를 만들어준다.
TokenAuthenticationFilter
@Component
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final AuthService authService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = getTokenFromRequest(request);
if(token != null && authService.validateToken(token)) {
Authentication authentication
= new UsernamePasswordAuthenticationToken(token, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.replace("Bearer ", "");
}
return null;
}
}
OncePerRequestFilter
- 스프링 시큐리티에서 제공하는 추상 클래스
- 특정 요청이 들어올 때 한 번만 필터링 로직을 수행하도록 함
getTokenFromRequest
- 요청 http header에서 Authorization 속성을 가져옴
- Bearer로 시작하는 문자열이면서 null이 아니면 Bearer를 제거한 나머지 문자열을 반환
doFilterInternal
- 요청 http header에서 토큰 값을 가져옴
- 토큰 값이 null이 아니면서 유효한 토큰이면 인증 정보를 스프링 시큐리티에 세팅
Spring Security 필터 추가
// SecurityConfig
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenAuthenticationFilter tokenAuthenticationFilter) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/**", "/", "/index.html","/api/auth/*").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated())
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 필터 추가
.exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(((request, response, authException)
-> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))); // 401 응답 반환
return http.build();
}
addFilterBefore()로 아까 만들었던 필터를 추가해주면 이제 API 요청마다 Authorization 헤더를 검사하게 된다.
이제 거의 다 한 것 같지만 제일 중요한게 하나 남았다.
바로 프론트에서 로그인 api 요청의 응답 데이터를 받고 token 값을 세팅해줘야한다.
localStorage 세팅
login.html
async function login(event) {
event.preventDefault();
const formData = {
userId : document.getElementById('userId').value
, password : document.getElementById('password').value
};
try {
const token = localStorage.getItem("token");
const response = await fetch('/api/auth/login', {
method : 'POST',
headers : {
'Content-Type' : 'application/json'
},
body: JSON.stringify(formData)
});
if(!response.ok) {
throw new Error('아이디와 비밀번호를 확인하세요.');
}
const data = await response.json();
localStorage.setItem("token", data.token); // token 값 추가
alert("로그인 성공");
window.location.href = "../index.html";
} catch(error) {
console.error('Error :', error);
alert("로그인 실패: " + error.message);
}
}
그리고 인증이 필요한 페이지에서 /api/auth/validate api를 사용해 token의 유효 여부를 체크한다.
test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h2>인증이 필요한 페이지</h2>
<script>
window.onload = async function() {
const token = localStorage.getItem("token");
if(!token) {
redirectToLogin();
return;
}
try {
const response = await fetch("/api/auth/validate", {
method : "GET",
headers: {
"Authorization":"Bearer " + token
}
});
if(!response.ok) {
throw new Error("유효하지 않은 토큰");
}
}catch(error) {
redirectToLogin();
}
};
function redirectToLogin() {
alert("로그인이 필요합니다.");
localStorage.removeItem("token"); // 유효하지 않은 토큰 삭제
window.location.href = "/member/login.html";
}
</script>
</body>
</html>
이러면 로그인이 필요한 페이지에 token 유효성 검사 후 token이 유효하면 접근 가능하게하고 유효하지 않으면 login 페이지로 리다이렉트 시켜버리면 Bearer 토큰을 이용한 방식은 끝이 났다.
아 마지막으로 테스트 해야지.
로그인 없이 일단 인증이 필요한 페이지로 접속해보자. Local Storage는 당연히 비어있다. 이 화면에서 alert의 확인 버튼을 누르면 로그인 페이지로 리다이렉트 된다.
뒤로가기 후 회원가입하고 로그인을 해보자.
로그인이 성공하면 localStorage에 token이 저장된다.
그리고 인증 필요 화면으로 이동!
로그인 화면으로 리다이렉트 되지 않고 정상적으로 페이지가 로드된것을 볼 수 있다.
Bearer 토큰은 이렇게 프론트에서 토큰을 localStorage 또는 sessionStorage에 저장 후 API 요청 시마다 Authorization 헤더에 포함시키는 방식이다.
이런 경우 공통화가 필수이다. api 인증을 보낼 때마다 각 화면 스크립트에서 /api/auth/validate api를 호출하는 소스를 작성하면 유지보수도 어렵다.
js 파일을 따로 빼서 공통화를 추천한다.
나는 임시로 거쳐갈 테스트 뿐이라 공통화는 하지 않았다. Bearer 토큰 인증 방식에 대한 간단한 구현과 이해만 하고 이제 다음에는 JWT 토큰을 사용한 방식을 구현해볼까한다.
아니면 로그인 기능을 조금 더 강화할 수도 있겠다.
아무튼 오늘도 즐거운 코딩..