기본적으로 서버에서 내려주는 로그인 page 사용 불가시 사용
id/pw로 토큰을 만들어 올린 뒤 서버 인증이 완료되면 principal 객체를 내려주는 방식으로 주로 사용
설정 방법
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic()
;
}
}
SecurityContext 에 인증된 토큰이 없다면 아래와 같은 포멧의 토큰을 받아서 인증처리를 하고 과정 진행
http 에서는 header 에 username:password 값이 묻어서 가기 때문에 보안에 매우 취약
-> https 프로토콜에서 사용할 것을 권장
최초 로그인시에만 인증을 처리하고, 이후에는 session에 의존
RememberMe 를 설정한 경우, remember-me 쿠키가 브라우저에 저장되기 때문에 세션이 만료된 이후라도 브라우저 기반의 앱에서는 장시간 서비스를 로그인 페이지를 거치지 않고 이용 가능
에러가 나면 401 (UnAuthorized) 에러를 내려보냄
@RestController
public class HomeController {
@GetMapping("/greeting")
public String greeting(){
return "hello";
}
@PostMapping("/greeting")
public String greeting(@RequestBody String name){
return "hello " + name;
}
}
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser(
User.withDefaultPasswordEncoder()
.username("user1")
.password("1111")
.roles("USER")
.build()
);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// csrf disable 시
// -> 서로 다른 보안 정책이 적용된 두개의 페이지가 서버에 공존해야되는 경우엔.. disable 할 수 없다
.csrf().disable()
.authorizeRequests().anyRequest().authenticated()
.and()
// basic filter 작동
.httpBasic()
;
}
}
test 코드 작성
-> RestTemplate
: Spring에서 HTTP 통신을 RESTful 형식에 맞게 손쉬운 사용을 제공해주는 템플릿
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BasicAuthenticationTest {
@LocalServerPort
int port;
RestTemplate client = new RestTemplate();
private String greetingUrl(){
return "http://localhost:" + port+ "/greeting";
}
@DisplayName("1, 인증 실패")
@Test
void test_1(){
HttpClientErrorException exception = assertThrows(HttpClientErrorException.class, ()->{
client.getForObject(greetingUrl(), String.class);
});
assertEquals(401, exception.getRawStatusCode());
}
@DisplayName("2, 인증 성공")
@Test
void test_2(){
// HttpEntity에 Header를 실어 갈려는 경우
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(
"user1:1111".getBytes()
));
HttpEntity entity = new HttpEntity(null, headers);
ResponseEntity<String> res = client.exchange(greetingUrl(), HttpMethod.GET, entity, String.class);
assertEquals("hello", res.getBody());
}
@DisplayName("3, 인증 성공")
@Test
void test_3(){
// TestRestTemplate에서는 기본적으로 Basic 토큰 지원
TestRestTemplate testClient = new TestRestTemplate("user1", "1111");
String response = testClient.getForObject(greetingUrl(), String.class);
assertEquals("hello", response);
}
@DisplayName("4, POST 인증 테스트")
@Test
void test_4(){
// post는 csrf filter가 작동(disable시 오류 X)
TestRestTemplate testClient2 = new TestRestTemplate("user1", "1111");
ResponseEntity<String> resp = testClient2.postForEntity(greetingUrl(), "kim", String.class);
assertEquals("hello kim", resp.getBody());
}
}
csrf disable 시
-> 서로 다른 보안 정책이 적용된 두개의 페이지가 서버에 공존해야되는 경우엔.. disable 할 수 없다
Web에서 되는 리소스가 mobile,SPA에서도 되도록 하기 위해서는 또다른 filter chain proxy를 configure를 통해 구성해야 함
Security config
// 디폴트 리퀘스트는 Security Config가 처리해야 하기에
@Order(2)
@EnableWebSecurity(debug = false)
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final StudentManager studentManager;
private final TeacherManager teacherManager;
public SecurityConfig(StudentManager studentManager, TeacherManager teacherManager) {
this.studentManager = studentManager;
this.teacherManager = teacherManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(studentManager);
auth.authenticationProvider(teacherManager);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
CustomLoginFilter filter = new CustomLoginFilter(authenticationManager());
http
.authorizeRequests(request->
request.antMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(
login->login.loginPage("/login")
.permitAll()
.defaultSuccessUrl("/", false)
.failureUrl("/login-error")
)
.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class)
.logout(logout->logout.logoutSuccessUrl("/"))
.exceptionHandling(e->e.accessDeniedPage("/access-denied"))
;
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
;
}
}
mobile Security Config(또 다른 filter chain proxy 구성)
@Order(1)
@Configuration
//@EnableWebSecurity(debug = true)
//@EnableGlobalMethodSecurity(prePostEnabled = true) -> 이 두 어노테이션은 중복선언 되있기에 configuration만 선언
public class MobileSecurityConfig extends WebSecurityConfigurerAdapter {
private final StudentManager studentManager;
private final TeacherManager teacherManager;
public MobileSecurityConfig(StudentManager studentManager, TeacherManager teacherManager) {
this.studentManager = studentManager;
this.teacherManager = teacherManager;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(studentManager);
auth.authenticationProvider(teacherManager);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 모바일 쪽에서 날라오는 convention을 두어야 함
// api 서비스로 호출하게 하는 것
.antMatcher("/api/**")
.csrf().disable()
.authorizeRequests(request-> request.anyRequest().authenticated())
.httpBasic();
}
}
ApiTeacherController
@RestController
@RequestMapping("/api/teacher")
public class ApiTeacherController {
@Autowired
StudentManager studentManager;
@PreAuthorize("hasAnyAuthority('ROLE_TEACHER')")
@GetMapping("/students")
public List<Student> studentList(@AuthenticationPrincipal Teacher teacher){
return studentManager.myStudentList(teacher.getId());
}
}
TeacherManager
@Component
public class TeacherManager implements AuthenticationProvider, InitializingBean {
private HashMap<String, Teacher> teacherDB = new HashMap<>();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
if(teacherDB.containsKey(token.getName())){
Teacher teacher = teacherDB.get(token.getName());
return TeacherAuthenticationToken.builder()
.principal(teacher)
.details(teacher.getUsername())
.authenticated(true)
.build();
}
return null;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication == UsernamePasswordAuthenticationToken.class;
}
@Override
public void afterPropertiesSet() throws Exception {
Set.of(
new Teacher("choi", "최선생", Set.of(new SimpleGrantedAuthority("ROLE_TEACHER")), null)
).forEach(s->
teacherDB.put(s.getId(), s)
);
}
}
Debug를 통해 BasicAuthenticationFilter를 들여다 보면
BasicAuthenticationFilter
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
....
if (authenticationIsRequired(username)) {
Authentication authResult = this.authenticationManager.authenticate(authRequest);
...
}
Basic 토큰 형태의 Request를 UserName 형태의 토큰으로 convert 한후에
authenticationManager.authenticate를 통해 Provider 중 TeacherManager가 인증을 해주는 방식
-> 처음엔 StudentManager가 검증을 시작하지만 return 값이 null이여서 이후 TeacherManager로 인증 권한을 넘김
test 코드
@DisplayName("1. 학생 조사")
@Test
void test_1() throws JsonProcessingException{
String url = format("http://localhost:%d/api/teacher/students", port);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString(
"choi:1".getBytes() // BasicAuthentication을 통한 인증
));
HttpEntity<String> entity = new HttpEntity<>("", httpHeaders);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
List<Student> list = new ObjectMapper().readValue(response.getBody(),
new TypeReference<List<Student>>() {
});
System.out.println(list);
assertEquals(3, list.size());
}