유저가 회원가입 데이터를 입력하면 중복검사를 통해 중복이 아니면 DB에 저장되어 회원가입이 성공되고 중복이라면 예외처리하여 에러문을 반환해주는 코드를 작성했었다.
하지만 데이터를 저장할때 비밀번호 같은 경우는 외부에 노출이 되면 안되기 때문에 암호화 작업이 필요하다 이때, 필요한 것이 Spring Security이다.
파일 구조
인증 VS 인가
- 인증은 사용자가 누구인지 확인하는 단계를 말한다. 대표적인 예로는 로그인이 있으며 DB에 저장되어 있는 데이터와 유저가 입력한 데이터를 비교하여 일치 여부를 확인한다. 만약 일치한다면 토큰을 발행해주고 일치하지 않다면 예외처리를 던져준다.
- 인가는 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정을 의미한다. 대표적인 예로는 공지사항 같은 관리자만 입력할 수 있는 페이지에서는 일반 유저에게 글작성 권한을 주지 않는 것이다.
기존 SpringBoot 통신 동작 구조
SpringSecurity 통신 동작 구조
이와 같이 SpringSecurity는 기존의 방식에 필터체인이 추가가 되었는데 필터 체인은 서블릿 컨테이너에서 관리하는 ApplicationFilterChani을 의미한다. 즉, 필터체인에서 인증, 인가를 허가해준다.
클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URL을 확인해서 필터와 서블릿을 매핑해준다.
Spring Security를 사용하기 위해서는 Gradle에 해당 dependencies를 추가해줘야 한다.
Intellij에서 직접 찾아서 추가해줘도 되고 mvnrepository 홈페이지를 통해 추가해줘도 된다.
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.7.5'
@EnableWebSecurity // 스프링 시큐리티를 활성화하는 어노테이션
@Configuration // 스프링의 기본 설정 정보들의 환경 세팅을 돕는 어노테이션
// @EnableGlobalMethodSecurity(prePostEnabled = true) // Controller에서 특정 페이지에 권한이 있는 유저만 접근을 허용할 경우
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity // SecurityFilterChain에서 요청에 접근할 수 있어서 인증, 인가 서비스에 사용
.httpBasic().disable() // http basic auth 기반으로 로그인 인증창이 뜬다. 기본 인증을 이용하지 않으려면 .disable()을 추가해준다.
.csrf().disable() // csrf, api server이용시 .disable (html tag를 통한 공격)
.cors() // 다른 도메인의 리소스에 대해 접근이 허용되는지 체크
.and() // 묶음 구분(httpBasic(),crsf,cors가 한묶음)
.authorizeRequests() // 각 경로 path별 권한 처리
.antMatchers("/api/**").permitAll() // 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.
.antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll()
.and()
.sessionManagement() // 세션 관리 기능을 작동한다. .maximunSessions(숫자)로 최대 허용가능 세션 수를 정할수 있다.(-1로 하면 무제한 허용)
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀(STATELESS는 인증 정보를 서버에 담지 않는다.)
.and()
// .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
//UserNamePasswordAuthenticationFilter적용하기 전에 JWTTokenFilter를 적용 하라는 뜻 입니다.
.build();
}
}
httpSecurity : SecurityFilterChain에서 요청에 접근할 수 있어서 인증, 인가 서비스에 사용
httpBasic( ) : http basic auth 기반으로 로그인 인증창이 뜬다. 기본 인증을 이용하지 않으려면 .disable()을 추가해준다.
csrf( ) : csrf, api server이용시 .disable (html tag를 통한 공격)
cors( ) : 다른 도메인의 리소스에 대해 접근이 허용되는지 체크
authorizeRequests( ) : 각 경로 path별 권한 처리
antMatchers(경로) : 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.
sessionManagement( ) : 세션 관리 기능을 작동한다.
sessionCreationPolicy(SessionCreationPolicy.STATELESS) : jwt사용하는 경우 씀(STATELESS는 인증 정보를 서버에 담지 않는다.)
and( ) : 세션 구분을 한다(이전 세션이 끝났으면 새로 and를 통해 새로운 세션을 추가함)
@Configuration
public class EncrypterConfig {
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder(); // password를 인코딩 해줄때 쓰기 위함
}
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
// 사용자에게 데이터를 입력받기 위한 DTO
public class UserJoinRequest {
private String userName;
private String password;
private String email;
// 사용자에게 입력받은 데이터를 Entity로 보내줌
public User toEntity(String password){ // 비밀번호를 암호화 해야하기 때문에 password는 따로 입력받아 저장시킨다.
return User.builder()
.userName(this.userName)
.password(password)
.emailAddress(this.email)
.build();
}
}
@AllArgsConstructor
@Getter
public class UserLoginRequest {
private String userName;
private String password;
}
@AllArgsConstructor
@Getter
public class UserLoginResponse {
private String userName;
private String email;
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// Spring이 자동으로 Bean폴더를 DI를 해줌
// EncrypterConfig encoder = new EncrypterConfig().encodePwd(); 와 같은 의미
private final EncrypterConfig encrypterConfig;
// 위에 처럼 내가 설정한 Config파일을 호출하여 사용해도 되지만 Spring에서는 기존 BCryptPasswordEncoder 클래스를
// DI를 하겠다 선언하면 알아서 해당 설정 Bean파일인 EncrypterConfig과 매칭을 시켜서 사용할 수 있게 해준다.
private final BCryptPasswordEncoder encoder;
// 회원가입 기능
// 데이터가 없을경우 정상동작, 데이터가 이미 있을겨우 오류 발생(회원가입 불가)
// 유저에게 입력받은 데이터 중복 검사 및 DB 저장
public UserDto join(UserJoinRequest request){
userRepository.findByUserName(request.getUserName())
// 내가 원하는 에러코드를 만들어서 설정하기
// enum클래스를 통해 미리 설정해둔 에러구조를 통해 에러를 넘겨준다.
.ifPresent(user -> {
throw new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME,String.format("Username :"+request.getUserName()));
});
// 비밀번호 암호화 하는방식 2가지
// 1. 내가 설정한 Config파일인 EncrypterConfig를 DI를받아 사용하는 법
// EncrypterConfig의 메서드인 encodePwd()를 호출하고 BCryptPasswordEncoder안에 있는 encode() 기능 사용
User saveUser = userRepository.save(request.toEntity(encrypterConfig.encodePwd().encode(request.getPassword())));
// 2. 기존 클래스인 BCryptPasswordEncoder를 DI를 받아 사용하는 법
// BCryptPasswordEncoder클래스안에 있는 메서드 encode() 기능 사용 => 자동으로 EncrypterConfig Bean과 연결됨
User saveUser2 = userRepository.save(request.toEntity(encoder.encode(request.getPassword()))); // UserJoinRequest -> User Entity변환후 데이터 DB 저장 , password는 암호화 하여 저장
return UserDto.fromEntity(saveUser2); // User에게 입력받아 회원가입한 데이터를 UserDto에 저장함
}
}
암호화를 하기 위해서는 BCryptPasswordEncoder 클래스를 통해 할 수 있는데 이때 DI하는 법이 2가지가 있다.
(1) 내가 설정한 Bean 폴더인 EncrypterConfig와 의존
- Bean 폴더인 EncrypterConfig와 직접적으로 의존하게 됨. 하지만 사용할 때는 EncrypterConfig폴더를 한번 거쳐서 EncrypterConfig안에 있는 BCryptPasswordEncoder의 메서드를 사용할 수 있다.
- 코드를 보면 DB에 데이터를 저장하는 부분에서 encrypterConfig.encodePwd().encode(request.getPassword()) 를 통해 EncrypterConfig안에 있는 메서드 encodePwd( )안에 BCryptPasswordEncoder의 기능인 encode를 통해 패스워드를 암호화하는 것을 알 수 있다.
(2) BCryptPasswordEncoder 클래스 의존
- 자동으로 위에서 BCryptPasswordEncoder 설정한 Bean 파일인 EncrypterConfig와 매핑되어 의존하게 된다. 하지만 기능적으로 사용할 때는 BCryptPasswordEncoder안에 있는 메서드를 바로 사용할 수 있다.
- 코드를 보면 DB에 데이터를 저장하는 부분에서 바로 BCryptPasswordEncoder의 기능인 encode를 통해 패스워드를 암호화하는 것을 알 수 있다.
이제는 회원가입은 되었으니 로그인을 할 차례인데 로그인을 위해서는 기존에 저장되어 있는 암호화된 데이터와 유저가 입력한 데이터를 비교한 후 다르면 예외처리, 맞으면 토큰을 발행하는 방식으로 구현한다. 이때 발행되는 토큰이 JWT이다.
Header
토큰의 타입과 해시 암호화 알고리즘으로 구성이 되어 있다. 첫째는 토큰의 유형을 나타내고 두번째는 해시 알고리즘(SHA256, RSA, HS256..)을 나타내는 부분이다.
Header 구조 { "typ":"JWT", "alg":"HS256" }
Payload
토큰에 담을 클레임 정보를 보함하고 있다. 다음은 표준스펙으로 정의되어 있는 Claim 스펙이다.(필수는 아니고 대표적인 것들이다)
(1) iss(Issuer) : 토큰 발급자
(2) sub(Subject) : 토큰 제목
(3) aud(Audience) : 토큰 대상자
(4) exp(Expiration Time) : 토큰 만료 시간
(5) nbf(Not Before) : 토큰 활성 날짜(이 날짜 이전의 토큰은 활성화되지 않음을 보장)
(6) iat(Issued At) : 토큰 발급 시간
(7) jti(JWT Id) : JWT 토큰 식별자(Issuer가 여러명일 때 이를 구분하기 위한 값)Payload 구조 { "sub":"payload test", "iss":"manager", "exp":"1636989715", "iat":"1636987715" }
Sinature
가장 중요한 서명으로 암호화가 되어 있으므로 JWT.IO를 통해 이미지로 설명하겠다.
지금까지는 Header와 Payload를 보여줄 때는 인코딩 되어있던 값들을 JWT에 담겨있는 것처럼 디코딩된 상태를 사용했었는데 이제는 Header와 Payload를 디코딩한 값을 아래처럼 합치고 이를 서버가 가지고 있는 개인키(your-256-bit-secret)를 통해 암호화할 수 있다. 현재 저 빈칸부분에 서버에서 설정한 개인키값을 넣으면 암호화된 값을 풀어낼 수 있다. 따라서 개인키는 절대 외부에 노출이 되서는 안된다.
Spring Security를 사용하기 위해서는 Gradle에 해당 dependencies를 추가해줘야 한다.
Intellij에서 직접 찾아서 추가해줘도 되고 mvnrepository 홈페이지를 통해 추가해줘도 된다.
// mvnrepository에서 검색해서 넣음
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
jwt:
token:
secret: hello
public class JwtTokenUtil {
public static String createToken(String userName, long expireTimeMs, String key) {
Claims claims = Jwts.claims(); // 일종의 map
claims.put("userName", userName);
return Jwts.builder() // 토큰 생성
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis())) // 시작 시간 : 현재 시간기준으로 만들어짐
.setExpiration(new Date(System.currentTimeMillis() + expireTimeMs)) // 끝나는 시간 : 지금 시간 + 유지할 시간(입력받아옴)
.signWith(SignatureAlgorithm.HS256, key)
.compact()
;
}
}
이전 실습에 변경되거나 추가된 내용만 작성함
@AllArgsConstructor
@Getter
public class UserLoginResponse {
private String token;
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// Spring이 자동으로 Bean폴더를 DI를 해줌
// EncrypterConfig encoder = new EncrypterConfig().encodePwd(); 와 같은 의미
private final EncrypterConfig encrypterConfig;
// 위에 처럼 내가 설정한 Config파일을 호출하여 사용해도 되지만 Spring에서는 기존 BCryptPasswordEncoder 클래스를
// DI를 하겠다 선언하면 알아서 해당 설정 Bean파일인 EncrypterConfig과 매칭을 시켜서 사용할 수 있게 해준다.
private final BCryptPasswordEncoder encoder;
// jwt 토큰에서 토큰 이름을 숨겨두고 해당 이름을 호출한다.(코드상에 토큰 이름이 있으면 절대 안됨, 바로 해킹당함)
// application.yml 파일에 토큰 가짜 이름을 넣는다(실제 값은 environment variables에 넣는다)
@Value("${jwt.token.secret}")
private String secretkey; // application.yml에서 설정한 token 키의 값을 저장함
private long expireTimeMs = 1000*60*60; // 토큰 1시간
// 회원가입 기능
public UserDto join(UserJoinRequest request){
// 데이터가 없을경우 정상동작, 데이터가 이미 있을겨우 오류 발생(회원가입 불가)
// 유저에게 입력받은 데이터 중복 검사 및 DB 저장
userRepository.findByUserName(request.getUserName())
// 1. RuntimeException 에러타임 보내기(에러 설정클래스에서 RuntimeException에 해당하는 메서드 실행됨
// .ifPresent(user -> new RuntimeException("해당 UserName이 중복 됩니다")); // 데이터가 있을경우 예외처리(콘솔에만 출력됨)
// 2. 내가 원하는 에러코드를 만들어서 설정하기
// enum클래스를 통해 미리 설정해둔 에러구조를 통해 에러를 넘겨준다.
.ifPresent(user -> {
throw new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME,String.format("Username :"+request.getUserName()));
});
// 비밀번호 암호화 하는방식 2가지
// 1. 내가 설정한 Config파일인 EncrypterConfig를 DI를받아 사용하는 법
// EncrypterConfig의 메서드인 encodePwd()를 호출하고 BCryptPasswordEncoder안에 있는 encode() 기능 사용
User saveUser = userRepository.save(request.toEntity(encrypterConfig.encodePwd().encode(request.getPassword())));
// 2. 기존 클래스인 BCryptPasswordEncoder를 DI를 받아 사용하는 법
// BCryptPasswordEncoder클래스안에 있는 메서드 encode() 기능 사용 => 자동으로 EncrypterConfig Bean과 연결됨
User saveUser2 = userRepository.save(request.toEntity(encoder.encode(request.getPassword()))); // UserJoinRequest -> User Entity변환후 데이터 DB 저장 , password는 암호화 하여 저장
return UserDto.fromEntity(saveUser2); // User에게 입력받아 회원가입한 데이터를 UserDto에 저장함
}
// 로그인 기능
public String login(String userName, String password) {
// 유저이름(ID)이 있는지 확인
// 없다면 Not Found 에러 발생
User user = userRepository.findByUserName(userName)
.orElseThrow(()-> new HospitalReviewAppException(ErrorCode.NOT_FOUND,String.format("%s는 가입된 적이 없습니다.",userName)));
// password일치 하는지 여부 확인
if(!encoder.matches(password,user.getPassword())){ // encoder.matches는 암호화된 문자를 입력된 문자와 비교해주는 메서드이다
throw new HospitalReviewAppException(ErrorCode.INVALID_PASSWORD,String.format("비밀번호가 틀립니다."));
}
// 두가지 확인 중 에외가 없다면 token 발행
return JwtTokenUtil.createToken(userName,expireTimeMs,secretkey);
}
}
@WebMvcTest
@WebAppConfiguration
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
UserService userService;
@MockBean
BCryptPasswordEncoder encoder;
@Autowired
ObjectMapper objectMapper;
UserJoinRequest userJoinRequest;
@BeforeEach // 중복되는 코드 따로 빼내서 사용
public void setup() {
userJoinRequest = UserJoinRequest.builder()
.userName("han")
.password("1q2w3e4r")
.email("oceanfog1@gmail.com")
.build();
}
@Test
@DisplayName("회원가입 성공")
@WithMockUser
void join_success() throws Exception {
// given
User user = userJoinRequest.toEntity(encoder.encode(userJoinRequest.getPassword())); // 비밀번호 암호화
UserDto userDto = UserDto.fromEntity(user);
when(userService.join(any())).thenReturn(userDto);
// when, then
mockMvc.perform(post("/api/v1/users/join")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) // Json 타입으로 사용
.content(objectMapper.writeValueAsBytes(userJoinRequest))) // 삽입한 데이터 dto를 json 형식으로 변환
.andDo(print())
// userName 존재 여부 확인
.andExpect(jsonPath("$..userName").exists())
// userName의 값 비교
.andExpect(jsonPath("$..userName").value("han"))
.andExpect(status().isOk());
}
@Test
@DisplayName("회원가입 실패")
@WithMockUser
void join_fail() throws Exception {
setup();
// 이전에는 when/thenReturn을 통해 구현했는데 그렇게 하면 given 구역에서 when을 사용하면 헷갈릴 수 있으므로 given으로 구역을 표시하며 정확히 한다.
given(userService.join(any()))
.willThrow(new AppException(ErrorCode.DUPLICATED_USER_NAME, ""));
mockMvc.perform(post("/api/v1/users/join")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(userJoinRequest)))
.andDo(print())
.andExpect(status().isConflict());
verify(userService).join(any());
}
@Test
@DisplayName("로그인 실패 - id없음")
@WithMockUser
void login_fail1() throws Exception{
setup();
given(userService.login(any(),any()))
.willThrow(new AppException(ErrorCode.USERNAME_NOTFOUND, ""));
mockMvc.perform(post("/api/v1/users/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(new UserLoginRequest(userJoinRequest.getUserName(), userJoinRequest.getPassword()))))
.andDo(print())
.andExpect(status().isNotFound()); // id가 없으므로 찾을수가 없다 (404)
verify(userService).login(any(),any());
}
@Test
@DisplayName("로그인 실패 - pw틀림")
@WithMockUser
void login_fail2() throws Exception{
setup();
given(userService.login(any(),any()))
.willThrow(new AppException(ErrorCode.INVALID_PASSWORD, ""));
mockMvc.perform(post("/api/v1/users/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsBytes(new UserLoginRequest(userJoinRequest.getUserName(), userJoinRequest.getPassword()))))
.andDo(print())
.andExpect(status().isUnauthorized());
verify(userService).login(any(),any());
}
@Test
@DisplayName("로그인 성공")
@WithMockUser
void login_success() throws Exception{
String userName = "test";
String password = "1234";
given(userService.login(any(),any()))
.willReturn("token");
mockMvc.perform(post("/api/v1/users/login")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON) // Json 타입으로 사용
.content(objectMapper.writeValueAsBytes(new UserLoginRequest(userName, password)))) // 삽입한 데이터 dto를 json 형식으로 변환
.andDo(print())
// token 존재 여부
.andExpect(status().isOk());
verify(userService).login(any(),any());
}
}
로그인 실패
- given을 통해 Service의 login메서드에 ID,PW의 값을 아무런 값이나 넣어도 ID에 해당하는 값이 없기 때문에 willThrow를 통해 예외처리를 한다.
- Security Config에서 csrf( )를 설정해줬으므로 .with(csrf())를 지정해줘야지만 오류가 발생하지 않는다.
- 상태 기대값은 예외처리가 실행됬으므로 해당 에러가 출력이 되야 하므로 isNotFound()를 출력한다.
비밀번호 실패
- 해당 내용은 로그인 실패와 같고 오류 메세지만 다르게 설정한다.
로그인 성공
- 이전과 달리 given에서 반환값을 예외처리가 아닌 token을 반환하도록 구현하고 상태 기대값이 정상상태인 200이 출력되도록 구현한다.