하이브리드 앱이란 한 어플리케이션에서 모바일 환경과 웹 환경을 모두 응답해 줄 수 있는 어플리케이션이다 .
하이브리드 앱은 모바일 환경이나 SPA 같은 경우에는 API를 내려줘야 하고 웹 환경에서는 페이지를 내려줄 수 있도록 해줘야 한다.
스프링 시큐리티은 모바일 혹은 SPA 환경에서 BasicAuthenticationFilter를 사용하여 인증을 하고 ,
웹 환경에서는 UsernamePasswordFilter를 이용하여 인증을한다.
이번에는 한 어플리케이션에서 이둘을 함께 구현하는 방법을 알아보려 한다.
일단 두가지 환경의 Filter chain에 대해 설정할 config 파일을 만들어 줬다 .
선생님이 관리하는 학생들의 정보를 출력해주는 view 와 api를 처리하는 로직을 만들어서 하이브리드 앱을 구현해 보자
SecurityConfig(Web환경에서의 config)
package com.sp.fc.web.config;
import com.sp.fc.web.student.StudentManager;
import com.sp.fc.web.teacher.TeacherManager;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Order(2)
//세션이 없는 BasicAuthenticationToken 같은경우 debug 모드에서 에러가 난다.
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//학생의 인증을 제공할 authenticationProvider
private final StudentManager studentManager;
//선생님의 인증을 제공할 authenticationProvider
private final TeacherManager teacherManager;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//학생 선생님 authenticationProvider를 AuthenticationManager에 등록
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())
;
}
}
웹 환경에서는 직접 구현한 필터를 사용해 봤다 . UsernameAuthenticationFilter와 거의 비슷하고 다른점은 학생과 , 선생님의 Authority가 구분 된다 .
직접 구현한 provider를 등록하였고 , HttpSecurity 같은 경우에는 인덱스페이지 , login 페이지만 인증을 열어두고 나머지 요청에 대해서는 인증을 거치도록 설정했다 로그인 페이지를 열어두는 이유는 인증을 하러 로그인페이지로 넘겨줬는데 로그인 페이지에서도 인증을해야하여 인증이 불가능하게 되기 때문이다 .
직접 구현한 필터를 addFilterAt()으로 등록했다 addFilterAt()은 첫번째 파라미터로 사용하길 원하는 필터 , 두번째로 대체하길 원하는필터를 지정해서 직접 만든 필터를 특정 필터를 대체하도록 해준다 .formLogin() 부분이 주석 처리 되어있는데 사실 스프링시큐리티에서 구현된 formLogin() 과 같은 필터들에는 직접 구현한 것보다 훨씬 많은 기능들이 구현되어 있기 때문에 위의 코드에서 주석을 풀어서 .formLogin()과 직접구현한 필터를 같이 사용해도 문제가 되지는 않는다. 일단 다른 기능들은 설정을 안할 것이기 떄문에 주석처리 해뒀다.
configure(WebSecurity web)에서는 StaticResources에 대해서 스프링 시큐리티를 거치지 않도록 설정했다 .
맨위의 어노테이션을 살펴보자
@Order(2)
- 여러개의 필터체인에서 우선 순위를 둘때 사용한다 순서가 높을 수록 우선시 된다
@EnableWebSecurity(debug = true)
- SpringSecurity를 동작하게 해주고 debug 모드로 해두면 요청마다 어떤 필터를 거치는지와 요청에 대한 정보가 콘솔에 뜬다
*세션이 없는 BasicAuthenticationToken 같은경우 debug 모드에서 에러가 난다.
@EnableGlobalMethodSecurity(prePostEnabled = true)
- 컨트롤러단에서 메서드가 요청을 처리하기 전에 권한을 체크할 수 있게 해준다
- 컨트롤러에서는 @PreAuthorize를 통해 권한을 체크할 수 있다.
@PreAuthorize("hasAuthority('ROLE_TEACHER')")
@GetMapping("/students")
public List<Student> studentList(@AuthenticationPrincipal Teacher teacher){
return studentManager.myStudents(teacher.getId());
}
}
MbSecurityConfig(모바일 환경에서의 config)
package com.sp.fc.web.config;
import com.sp.fc.web.student.StudentManager;
import com.sp.fc.web.teacher.TeacherManager;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Order(1)
@Configuration
public class MbSecurityConfig extends WebSecurityConfigurerAdapter {
private final StudentManager studentManager;
private final TeacherManager teacherManager;
public MbSecurityConfig(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.antMatcher("/api/**")
.csrf().disable()
.authorizeRequests(r -> r.anyRequest().authenticated())
.httpBasic();
}
}
모바일 환경에서의 config에서는 Order가 1로 되어있다. 웹 환경 필터가 , 모바일 환경 필터보다 먼저 실행 될 것이다.
모바일환경의 요청에서도 authority를 구분하기 위해서 studentManager와 teacherManager를 등록해 줬다 .
HttpSercurity는 폼 요청이 아니기 때문에 csrf를 꺼뒀고 , "/api" 하위의 모든 요청에 대해서 실행되도록 했다 .
또 /api로 들어오는 모든요청은 인증이 필요하다 .
.httpBasic를 설정해 두면 BasicAuthenticationFilter를 사용할 수 있다 .
TeacherManager 와 StudentManager를 살펴보자 (둘이 거의 비슷해서 StudentManager만 살펴보자)
StudentManager
package com.sp.fc.web.student;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class StudentManager implements AuthenticationProvider , InitializingBean {
private HashMap<String,Student> studentDB = new HashMap<>();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if(authentication instanceof UsernamePasswordAuthenticationToken){
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
if(studentDB.containsKey(token.getName())){
return getAuthenticationToken(token.getName());
}
return null;
}
StudentAuthenticationToken token = (StudentAuthenticationToken) authentication;
if(studentDB.containsKey(token.getCredentials()) ){
return getAuthenticationToken(token.getCredentials());
}
return null;
}
private StudentAuthenticationToken getAuthenticationToken(String id) {
Student student = studentDB.get(id);
return StudentAuthenticationToken.builder()
.principal(student)
.details(student.getUsername())
.authenticated(true)
.build();
}
@Override
public boolean supports(Class<?> authentication) {
return authentication == StudentAuthenticationToken.class ||
authentication == UsernamePasswordAuthenticationToken.class;
}
public List<Student> myStudents(String teacherId){
return studentDB.values().stream().filter(s -> s.getTeacherId().equals(teacherId))
.collect(Collectors.toList());
}
@Override
public void afterPropertiesSet() throws Exception {
Set.of(
new Student("ugo","우고",Set.of(new SimpleGrantedAuthority("ROLE_STUDENT")),"gang"),
new Student("hwang","황방",Set.of(new SimpleGrantedAuthority("ROLE_STUDENT")),"gang"),
new Student("kong","공길",Set.of(new SimpleGrantedAuthority("ROLE_STUDENT")),"gang")
).forEach(s -> studentDB.put(s.getId(),s));
}
}
AuthenticationManager의 구현체가 AuthenticationProvider이다
AuthenticationProvider를 직접 구현하려면 AuthenticationProvider를 implements 하면된다. Authenticate() 메서드는
실질적으로 인증을하고 토큰을 발행해준다 . Authenticate의 파라미터로 들어오는 authentication은 UsernamePasswordAuthenticationToken일 수도 있고 StudentAuthenticationToken 일 수도 있기 때문에 두 토큰에 대한 처리를 할 수 있게 코드를 작성해야 한다.
UsernamePasswordAuthenticationToken 같은 경우에는 토큰에서 getName을 하여 이름을 얻어내서 DB를 뒤지고 있으면 토큰을 발행해주고 없으면 null을 리턴한다 . 중요한 것은 만약 처리할 수 없는 토큰이면 null을 리턴해줘야 한다.
현재 studentDB라는 Set을 Db라 생각하고 사용하고 있기 떄문에
StudentAuthenticationToken는 직접구현한 필터에서 credential에 username을 담도록 구현을 해놔서(별의미는 없다 ) credential로 db를 뒤지고 있다 마찬가지로 존재한다면 디비에서 실제 엔티티를 가져와서 엔티티의 정보를 토대로 토큰에 데이터를 채워 넣고 발행한다.
다음으로는 support메서드를 보자 support는 해당 AuthenticationManager가 어떤 형식의 토큰에 대해서 처리할 수 있는지를 정의한다 , UsernamePasswordAuthenticationToken 과 StudentAuthenticationToken을 지정해 줬다.
myStudents는 학생 정보를 출력해주는 메서드이다 . student에 teacherId가 같은 것만 모아서 리스트로 만들어 리턴한다 .
afterPropertiesSet은 데이터를 집어 넣는 코드이다 . InitializingBean을 구현하면 afterPropertiesSet를 통해 bean이 스프링에 올라갈때에 데이터를 초기화 시킬 수 있다
컨트롤러를 준비하자 API 요청에 대한 컨트롤러와 view 요청에 대한 컨트롤러 두가지를 만들었다 .
View 요청 컨트롤러
@Controller
@RequestMapping("/teacher")
@RequiredArgsConstructor
public class TeacherController {
private final StudentManager studentManager;
@PreAuthorize("hasAnyAuthority('ROLE_TEACHER')")
@GetMapping("/main")
public String main(@AuthenticationPrincipal Teacher teacher , Model model){
model.addAttribute("studentList",studentManager.myStudents(teacher.getId()));
return "TeacherMain";
}
}
@PreAuthorize("hasAnyAuthority('ROLE_TEACHER')")를 통해 요청 처리전 Authority에 대해 체크하고 있다 .
요청 처리는 간단하게 studentManager.myStudents를 통해 선생님이 관리하는 학생목록을 모델에 담고 페이지를 리턴하고 있다 .
서버를 돌리고 요청을 해보자 .
db에 등록한 gang 이란 유저로 로그인했다 . gang은 hwang , ugo, kong 학생의 선생님이다.
페이지가 잘 내려왔고 , 리스트가 만들어 졌다 .
모바일 환경 API요청 컨트롤러
@RestController
@RequestMapping("/api/teacher")
@RequiredArgsConstructor
public class MbTeacherController{
private final StudentManager studentManager;
@PreAuthorize("hasAuthority('ROLE_TEACHER')")
@GetMapping("/students")
public List<Student> studentList(@AuthenticationPrincipal Teacher teacher){
return studentManager.myStudents(teacher.getId());
}
}
권한 체크 요청처리 모두 웹환경의 것과 같다 하지만 API요청이기 떄문에 API스펙을 내려줘야한다 . 그냥 List로 넘겨줬다 .
ApiTester로 요청을 해보자
Api 요청의 경우 요청 Header의 Authorization에 아이디와 비밀번호를 base64로 인코딩하여 Basic 토큰을 만들어 넘겨줘야한다. 넘겨주면 BasicAuthenticationFilter가 이를 받아서 인증을 처리할 것이다
API 테스터에 헤더에 인증정보를 입력하면 자동으로 포맷팅 해주는 기능이 있다 .
나는 아이디는 gang 비밀번호는 1 로 하였으니 인코딩하기 전 basic 토큰의 형태는 아래와 같다
Basic gang:1
아래 그림에 잇는것이 Base64로 인코딩 된 후의 형태이다 . Basic이 앞에 붙는 이유는 Basic토큰임을 표시하기 위해서이다 .
(Basic 토큰은 아이디 비밀번호를 헤더에 달아 가져가기 때문에 보안상 취약하다 Base64로 인코딩 했다 하지만 디코딩하기 쉽다
그렇기 떄문에 보통 JWT 토큰을 사용한다고 한다 .)
결과
리스트가 잘 내려왔다 .
간단한 테스트도 작성해 보자 ,
import com.sp.fc.web.student.Student;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MultiChainProxyAppTest {
@LocalServerPort
int port;
TestRestTemplate testClient = new TestRestTemplate("gang", "1111");
@DisplayName("1. gang:1 로 로그인해서 학생 리스트를 내려받는다.")
@Test
void test_1(){
String url = "http://localhost:" + port + "/api/teacher/students";
ResponseEntity<List<Student>> resp = testClient.exchange(url, HttpMethod.GET,
null, new ParameterizedTypeReference<List<Student>>() {
});
assertNotNull(resp.getBody());
assertEquals(3, resp.getBody().size());
}
}
돌려보니 성공했다 .
사실 이렇게 authenticationProvider를 직접 구현하거나 할 일은 많지 않다고 한다. spring security가 어떤식으로 동작하는지 조금 알게되어 좋다.
'Spring Security' 카테고리의 다른 글
스프링시큐리티 공부 12 - 세션관리 (ConcurrentSessionFilter) (0) | 2021.07.06 |
---|---|
스프링시큐리티 공부 11 - 로그인 유지 (session과 remberme cookie) (0) | 2021.07.06 |
스프링시큐리티 공부 8 - BasicAuthenticationFilter (0) | 2021.07.01 |
스프링시큐리티 공부 6 - RoleHierarchy, authenticationDetailsSource 설정하기 (1) | 2021.06.29 |
스프링시큐리티 공부 5 - form 로그인 1 (0) | 2021.06.29 |