Spring Security는 Authentication(인증), Authorization(권한)에 대한 기능을 제공하는 프레임워크입니다. Spring Security를 사용하지 않는다면 커스텀으로 세션, redirect 등 보안에 관련된 부분을 구현해야하지만 이 프레임워크를 사용한다면 이에 관한 많은 기능들을 지원해줍니다.
여기서 인증이란 애플리케이션을 이용할 수 있도록 이용자가 되기 위한 인증을 의미하며 권한은 애플리케이션을 이용할 수 있도록 인가가 되었는지를 의미합니다.
구조를 살펴보겠습니다.
1) Http Request(login)을 시도합니다.
2) username, password를 기반으로 UsernamePasswordAuthenticationToken을 생성합니다.
3) Filter의 흐름에 따라 이 Token을 AuthenticationManager에 보냅니다.
4) 순차적으로 진행되면서 User에서 객체 정보를 받아와 인증을 수행합니다.
5) 완전한 인증 객체를 받아와 SecurityContext에 저장합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
security라는 패키지를 만들고 WebSecurity 클래스를 작성하도록 하겠습니다.
@Configuration
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests().antMatchers("/register/**").permitAll();
}
}
코드를 살펴보면 csrf().disable()이라는 코드를 살펴볼 수 있습니다. 우선 csrf란 공격자가 애플리케이션에 로그인한 유저처럼 행사해 본래의 목적이 있는 유저와 달리 공격자의 의도대로 특정 웹페이지에 대한 내용을 수정, 삭제 등의 작업을 하게 만드는 공격 방법입니다.
그래서 csrf()는 이러한 공격을 막기위한 Spring Security의 보안 방법으로 이에 대한 설정을 하게 된다면 로그인 폼에 다음과 같은 코드를 삽입해야 합니다.
<form>
<input type="hidden" name="{_csrf.name}" value="${_csrf.token}" />
</form>
우선 백엔드부터 완성하고 프론트엔드를 진행할 예정이기 때문에 disable()로 막아놓도록 하겠습니다.
그리고 http.authorizeRequest().antMatchers("/register/**").permitAll(); 라는 코드는 /register/로 들어오는 요청들을 허락하겠다는 의미입니다.
register에 관한 부분을 Spring Security를 연동하여 완성시켜야 하기 때문에 미처 구현하지 못했던 비밀번호 암호화에 관한 부분을 작성하도록 하겠습니다.
@Service
@Slf4j
public class AuthServiceImpl implements AuthService {
private AuthRepository authRepository;
private BCryptPasswordEncoder passwordEncoder;
@Autowired
public AuthServiceImpl(
AuthRepository authRepository,
BCryptPasswordEncoder passwordEncoder
) {
this.authRepository = authRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
@Override
public UserDto registerUser(UserDto userDto) {
log.info("Auth Service's Service Layer :: Call register Method!");
UserEntity userEntity = UserEntity.builder()
.email(userDto.getEmail())
.nickname(userDto.getNickname())
.phoneNumber(userDto.getPhoneNumber())
.encryptedPwd(passwordEncoder.encode(userDto.getPassword()))
.userId(UUID.randomUUID().toString())
.createdAt(DateUtil.dateNow())
.build();
authRepository.save(userEntity);
return userDto.builder()
.email(userEntity.getEmail())
.nickname(userEntity.getNickname())
.phoneNumber(userEntity.getPhoneNumber())
.encryptedPwd(userEntity.getEncryptedPwd())
.userId(userEntity.getUserId())
.build();
}
}
그리고 BCryptPasswordEncoder를 생성하면 autowired가 되지않는다는 에러가 뜰텐데 AuthServiceApplication클래스로 가서 bean주입을 통해 에러를 해결하도록 하겠습니다.
@SpringBootApplication
@EnableEurekaClient
public class AuthServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AuthServiceApplication.class, args);
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
그러면 짧게나마 작성한 코드를 통해 SpringSecurity에 register요청이 잘 되는지, 그리고 패스워드가 잘 암호화가 되었는지 확인해보도록 하겠습니다.
postman요청 그리고 반환값을 확인할 수 있으며, database에도 잘 저장되어있는 모습을 살펴볼 수 있습니다.
앞서 만들었던 security패키지에 AuthenticationFilter라는 클래스를 추가하도록 하겠습니다.
public class AuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws AuthenticationException {
try {
RequestLogin creds = new ObjectMapper().readValue(
request.getInputStream(),
RequestLogin.class
);
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
creds.getEmail(),
creds.getPassword(),
new ArrayList<>()
)
);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult
) throws IOException, ServletException {
}
}
attemptAuthentication 메서드를 살펴보겠습니다.
해당 메서드에서 RequestLogin creds = new ObjectMapper().readValue(request.getInputStream(), RequestLogin.class);
이 문장은 RequestLogin이라는 객체를 만들 것인데 로그인 http요청 중 body에 있는 내용을 RequestLogin.class로 매핑하겠다는 의미입니다.
그리고 return getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(creds.getEmail(), creds.getPassword(), new ArrayList<>()));
이 문장은 UsernamePasswordAuthenticationToken을 만들어 getAuthenticationManager().authenticate()에 넣어주면 이 Token값을 이용해 아이디와 패스워드를 비교해주겠다는 의미입니다.
그리고 이 AuthenticaionFilter라는 클래스를 이용해 WebSecurity 클래스를 수정하도록 하겠습니다.
@Configuration
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
private AuthService authService;
private BCryptPasswordEncoder passwordEncoder;
@Autowired
public WebSecurity(
AuthService authService,
BCryptPasswordEncoder passwordEncoder
) {
this.authService = authService;
this.passwordEncoder = passwordEncoder;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.authorizeRequests().antMatchers("/**").hasIpAddress("10.0.15.41")
.and().addFilter(getAuthenticationFilter());
}
private AuthenticationFilter getAuthenticationFilter() throws Exception {
AuthenticationFilter authenticationFilter = new AuthenticationFilter();
authenticationFilter.setAuthenticationManager(authenticationManager());
return authenticationFilter;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// dp.pwd(encrypted) == request.pwd(encode)
auth.userDetailsService(authService)
.passwordEncoder(passwordEncoder);
}
}
configure메서드에서 바뀐점은 모든 요청에 대해 권한이 필요하게끔 되었다는 점입니다. 이전에는 /register에 대한 요청을 확인하기 위해 /register 요청만 가능하게끔 했지만 이제는 모든 요청에 대해 권한이 필요하게 설정하였고, AuthenticationFilter클래스를 Filter로써 추가하도록 하겠습니다.
그리고 인증에 관한 작업을 위해 새로운 configure메서드를 오버라이딩 하겠습니다. AuthenticationManagerBuilder객체를 가져와 로그인 처리에 관한 사용자 정보를 검색합니다. 이때 db에 저장된 패스워드는 encode된 패스워드이므로 요청했을 때의 패스워드 또한 encode해서 비교를 해야합니다.
auth.userDetailsService(authService).passwordEncoder를 입력하면 authService에서 오류 메시지를 확인할 수 있습니다. 이를 위해서 authService에 UserDetailsService인터페이스를 상속하도록 하겠습니다.
아마도 서비스들이 eureka-server에 등록되어 있기 때문에 이 전체 서비스들을 포괄할 수 있는 eureka-server 인스턴스의 ip번호는 허용이 가능하지 않나 라고 생각합니다.
public interface AuthService extends UserDetailsService {
UserDto registerUser(UserDto userDto);
}
상속을 받았으므로 Impl클래스에서 loadUserByUsername클래스를 오버라이딩 하겠습니다.
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
UserEntity userEntity = authRepository.findByEmail(email);
if(userEntity == null) throw new UsernameNotFoundException(email);
return new User(
userEntity.getEmail(),
userEntity.getEncryptedPwd(),
true,
true,
true,
true,
new ArrayList<>()
);
}
}
이 메서드는 email값을 받아와 엔티티를 만들고 이 엔티티를 기반으로 User라는 객체를 생성해 반환해주는 객체입니다. authRepository.findByEmail(email)이라는 메서드를 하나 생성해주고 apigateway-service에서 route정보를 수정해주도록 하겠습니다.
@Repository
public interface AuthRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByEmail(String email);
}
spring:
application:
name: apigateway-service
cloud:
gateway:
routes:
- id: auth
uri: lb://AUTH-SERVICE
predicates:
- Path=/auth-service/login
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/auth-service/(?<segment>.*), /$\{segment}
- id: auth
uri: lb://AUTH-SERVICE
predicates:
- Path=/auth-service/register
- Method=POST
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/auth-service/(?<segment>.*), /$\{segment}
- id: auth
uri: lb://AUTH-SERVICE
predicates:
- Path=/auth-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/auth-service/(?<segment>.*), /$\{segment}
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
server:
port: ${port:8900}
eureka:
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: http://localhost:8761/eureka
application.yml파일에 routes정보들을 수정했습니다. /auth-service/login, /auth-service/register, /auth-service/ 총 3개의 route 정보를 수정하고 만들었는데 login, register는 POST 요청으로 들어왔을 때의 조건을 만들어주었으며, /auth-service/은 추후에 만들 유저정보 획득, 대여 정보, 게시글 정보 등 get 요청에 관한 부분을 위해 만들었습니다. 그리고 다음에 get요청 filters 아래에 필터를 추가하여 인가되었을 때만 이 요청이 허용되도록 필터를 만들도록 하겠습니다.
filters의 RewritePath에서 (?.*), /${segment}를 보실 수 있습니다. 현재 auth-service는 7000번이라는 개별적인 포트번호를 가지고 있고, AUTH-SERVICE라는 이름으로 Eureka-server에 등록되어있습니다. 즉, localhost:7000/auth-service/ 이런 식으로 요청이 되는데 여기서 prefix인 auth-service가 과연 개별 포트번호가 존재하는데 필요한지에 대한 의문이 들 수 있습니다. 그렇기 때문에 이 패턴을 이용해 auth-service라는 prefix를 걷어내도록 하겠습니다. 결과적으로 /auth-service/register라는 요청이 들어오면 컨트롤러에서는 /auth-service를 제외하고 /register로 경로를 재작성해 받게 됩니다. 그러므로 컨트롤러 전체 prefix인 RequestMapping("/auth-service")를 RequestMapping("/")로 만들어 주겠습니다.
@RestController
@RequestMapping("/")
public class AuthController {
...
}
로그인
인프런: Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA) - 이도원