Joswlv

Spring Security 사용기

2018-02-20

1. 왜 Spring Security 사용했나?

  • 사내 sso연동을 위한 절차가 복잡해서
  • 로그인/로그아웃/권한체크 등을 쉽게 구현하기 위해

2. 이야기하는 것

  • UserDetails로 로그인/로그아웃 구현 방식에 대해(REST방식 로그인)
  • SecurityContextHolder에대해서 이해한 것들

3. 이야기 시작

로그인 관련 MySql Table정보

create table user ( 
	username varchar(20), 
	password varchar(500), 
	name varchar(20), 
	isAccountNonExpired boolean, 
	isAccountNonLocked boolean, 
	isCredentialsNonExpired boolean, 
	isEnabled boolean 
);

create table authority (
    username varchar(20),
    authority_name varchar(20)
);

1. UserDetails를 implements한 클래스를 만든다.

@Getter
@Setter
@ToString
public class CustomUserModel implements UserDetails {
	private String username;
	private String password;
	private boolean isAccountNonExpired = true;
	private boolean isAccountNonLocked = true;
	private boolean isCredentialsNonExpired = true;
	private boolean isEnabled = true;
	private Collection<? extends GrantedAuthority> authorities;
}

2. ServiceLayer에서 UserDetailsService를 implements한 클래스를 만든다.

@Service
public class UserManageService implements UserDetailsService {
	@Autowired
	private CustomUserDao customUserDao;

	...
	
	public CustomUserModel readUser(String userName) {
		return customUserDao.readUser(userName);
	}

	public Collection<GrantedAuthority> getAuthorities(String username) {
		return customUserDao.readAuthority(username);
	}

	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		CustomUserModel customUserModel = customUserDao.readUser(username);
		customUserModel.setAuthorities(getAuthorities(username));
		return customUserModel;
	}
}

참고 Mybatis를 사용할 경우 ResultMap으로 Collection<GrantedAuthority> return 받음!!

<resultMap id="authorityMap" type="org.springframework.security.core.authority.SimpleGrantedAuthority">
	<constructor>
		<idArg column="authority_name" javaType="String"/>
	</constructor>
</resultMap>

3. SecurityConfig 파일은 만든다.

REST방법을 사용하는 경우 다음과 같이 사용한다. 아이디/패스워드를 json을 받아서 처리하니.. formLogin으로 처리해도 되지만, 따로 json처리를 해줘야하는 귀찮음이 있다.

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@ComponentScan(basePackages = {"com.addinfra.customtargeting.security"})
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
	UserManageService userManageService;

	@Autowired
	RestUnauthorizedEntryPoint authenticationEntryPoint;

	@Autowired
	RestAccessDeniedHandler restAccessDeniedHandler;

	@Autowired
	LogoutHandler logoutHandler;

	@Autowired
	public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userManageService);
	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring()
				.antMatchers("/*.js", "/*.css,/*.png", "/*.jpg", "/*.otf", "/*.eot", "/*.svg", "/*.ttf", "/*.woff", "/*.woff2", "/signin.html");
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.csrf().disable();
		http.authorizeRequests()
				.antMatchers(HttpMethod.POST, "/login").permitAll()
				.antMatchers(HttpMethod.POST, "/logout").permitAll()
				.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
				.antMatchers("/confirmAdList.html").hasAnyAuthority("admin")
				.antMatchers("/userList.html").hasAnyAuthority("admin")
				.anyRequest().authenticated()
				.and()
				.exceptionHandling()
				.authenticationEntryPoint(authenticationEntryPoint)
				.accessDeniedHandler(restAccessDeniedHandler);

		http.logout()
				.invalidateHttpSession(true)
				.deleteCookies("JSESSIONID")
				.logoutSuccessHandler(logoutHandler);
	}

	@Bean
	@Override
	public AuthenticationManager authenticationManagerBean() throws Exception {
		return super.authenticationManagerBean();
	}
}

다양한 Handler를 사용할 수 있다. 각 상황에 맞는 Handler를 구현해서 사용하면 된다.

프론트를 AngularJs를 사용하여 구현했다. 이때 logoutSuccessHandler를 사용해 pageRedirct를 할려고 했지만 잘되지 않아.. 우회적으로 Response를 프론트에서 받아 pageRedirct를 했다.

그리고 기본적으로 Spring Security에서 /logouturl요청에 대해서 filter가 걸려있다. 따로 Controller에서 /logout을 구현하지 않아도 자동으로 Session과 SecurityContextHolder가 초기화 된다.

@Component
public class LogoutHandler implements LogoutSuccessHandler {
	@Override
	public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		SecurityUtils.sendResponse(response,HttpServletResponse.SC_OK,"logoutOk");
	}
}

Login Controller 구현

formLogin과 달리 login처리를 따로 해줘야한다.

@RestController
public class LoginController {
	@Autowired
	UserManageService userManageService;

	@Autowired
	AuthenticationManager authenticationManager;

	@PostMapping("/login")
	public String login(@RequestBody AuthenticationRequest authenticationRequest, HttpSession session) {
		try {
			String username = authenticationRequest.getUsername();
			String password = authenticationRequest.getPassword();

			UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
			Authentication authentication = authenticationManager.authenticate(token);

			SecurityContextHolder.getContext().setAuthentication(authentication);
			session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
					SecurityContextHolder.getContext());
			
			return new AuthenticationToken(authentication.getName(), userManageService.getLevel(authentication.getAuthorities()), session.getId());

		} catch (BadCredentialsException e) {
			return JSONObject.quote("check_pw");
		} catch (InternalAuthenticationServiceException e) {
			return JSONObject.quote("check_id");
		} catch (Exception e) {
			return JSONObject.quote(e.getMessage());
		}
	}
}

UsernamePasswordAuthenticationToken에서 id/pw로 token을 만들어 authenticationManager에서 authenticate(인증)을 받고 SecurityContextHolder에 인증정보를 저장한다. 즉 authorization(인가)된 사용자라고 저장한다.

Logout에 대해서

Spring Scurity에 LogoutFilter클래스가 있다. 여기서 하는 일은 /logout url로 요청이 오면 SecurityContextHolder를 clear하고 session을 삭제한다.

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest)req;
    HttpServletResponse response = (HttpServletResponse)res;
    if (this.requiresLogout(request, response)) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Logging out user '" + auth + "' and transferring to logout destination");
        }

        this.handler.logout(request, response, auth);
        this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
    } else {
        chain.doFilter(request, response);
    }
}

아무런 설정을 하지 않으면 기본적으로 생성된 ScurityContextHolder는 ThreadLocalSecurityContextHolderStrategy에 저장된다.

여기서 궁금한점은 같은 아디로 여러 곳에서 로그인을 했을 때 한곳에서 로그아웃을 하면 다른 곳에서 로그인한 사용자도 로그아웃이 될까? (결론은 다른곳 사용자는 로그아웃이 안된다.)

public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    Assert.notNull(request, "HttpServletRequest required");
    if (this.invalidateHttpSession) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            this.logger.debug("Invalidating session: " + session.getId());
            session.invalidate();
        }
    }

    if (this.clearAuthentication) {
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication((Authentication)null);
    }

    SecurityContextHolder.clearContext();
}

위 소스는 혼란이 왔던 부분이다.

id/pw로만 인증을 할텐데.. 어떻게 다른 곳에서 로그인한 것을 구분할까… 여기에 답은 AuthenticationManager에서 사용자 session값도 함께 저장을 하기 때문에 각각에 사용자에 대해서 로그아웃처리를 할 수 있던것이다.

그럼 AuthenticationManager를 사용하지 않고 id/pw로만 인증과정을 직접구현해서 사용하면 한사람이 로그아웃하면 동일아이디로 로그인 한사용자 모두가 로그아웃 되겠지..

Reference


Comments