본문 바로가기

Spring 정리/Spring Security

RememberMe

반응형

OAUTH2나 JWT는 큰 규모의 프로젝트이므로 메모리 그리드 방식의 세션을 유지하는 방법, 로그인 유지 방법을 고려해야 한다. 이번에 정리한 RememberMe 는 세션을 이용하여 인증할텐대, 먼저 스프링에서 세션을 유지하는 필터 2개를 알아본다.

 

 

* 목차

스프링에서 세션 유지하는 필터 2개

SecurityContextPersistentFilter

RememberMeAuthenticationFilter

TokenBasedRememberMeServices 실습

loadUserByUsername() 구현방식

PersistentTokenBasedRememberMeServices 실습

 

 

 

* 스프링에서 세션 유지하는 필터 2개

 

 

스프링 시큐리티는 통행증인 Authentication을 발급해주면, 통행증을 보고 권한을 확인한다. 스프링 인증처리는 세션과 별도로 동작하도록 설계되어있기 때문에 session을 사용하던 사용하지 않던 AuthenticationAuthenticationProvider를 사용할 수 있다.

 

Authentication을 발급받는 것이 시큐리티의 주요 관심사인데, request가 올 때 Authentication을 어떻게 유지하는지도 중요하다. AuthenticationProvider가 여권에 도장을 찍어주듯이 통행증을 찍어주면, 그 출입증을 가지고 SecurityContextHolder에 통행증이 저장되고 정상적으로 request 요청이 수행된다.

 

로그인에서 세션 유지와 관련된 필터는 2개가 있다. ( SecurityContextPersistenceFilter, RememberMeAuthentication )

 

SecurityContextPersistenceFilter 세션이 유지되고 있는 동안 SecurityContext가 유지되도록 도와준다. 세션이 만료되면 로그인을 다시 해야 한다. 세션이 만료되어도 RememberMeAuthentication을 발급하면 재로그인하지 않고 계속 로그인을 유지시킬 수 있다.

 

이전에 적었떤 모든 스캔내용을 지우고 "com.sp.fc.config"에서 해결한다.

 

 

@SpringBootApplication(scanBasePackages = {
        "com.sp.fc.config",
        "com.sp.fc"
})
public class UserDetailsTestApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserDetailsTestApplication.class, args);
    }
}

 

@Configuration
@ComponentScan("com.sp.fc.user")
@EntityScan(basePackages = {
        "com.sp.fc.user.domain"
})
@EnableJpaRepositories(basePackages = {
        "com.sp.fc.user.repository"
})
public class UserAdminModule {
}

 

* SecurityContextPersistenceFilter

 

 

 SecurityContextPersistenceFilter는 이름에서 알다시피 SecurityContext를 영속화하는 책임을 가진다. 영속화는 SecurityContextRepository라는 저장소에서 이루어진다. 또한 한번 로그인을 하고나면, 이후에는 세션에 저장된 SecurityContext을 가져다가 SecurityContextHolder에 넣어주어 전반적인 요청에 SecurityContext를 사용할 수 있도록 해준다. SecurityContextRepository 저장소는 HttpSessionContextSecurityHolder를 저장한다. 작업이 끝나면 SecurityContextHolder를 clear해주기 떄문에 ThreadLocal에서만 안전하도록 보장을 해준다.

 

 

로그인에 성공한다면 브라우저에 JSESSIONID가 저장된다.

 

 

 

 

@Bean
public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
    return new ServletListenerRegistrationBean<HttpSessionEventPublisher>(new HttpSessionEventPublisher() {
        @Override
        public void sessionCreated(HttpSessionEvent event) {
            super.sessionCreated(event);
            System.out.printf("===> [%s] 세션 생성됨 %s \n", LocalDateTime.now(), event.getSession().getId());
        }

        @Override
        public void sessionDestroyed(HttpSessionEvent event) {
            super.sessionDestroyed(event);
            System.out.printf("===> [%s] 세션 만료됨 %s \n", LocalDateTime.now(), event.getSession().getId());
        }

        @Override
        public void sessionIdChanged(HttpSessionEvent event, String oldSessionId) {
            super.sessionIdChanged(event, oldSessionId);
            System.out.printf("===> [%s] 세션 아이디 변경 %s:%s \n", LocalDateTime.now(), oldSessionId, event.getSession().getId());
        }
    });
}

 

ServletListener로 세션의 생성, 만료, 아이디 변경등을 감지하는 동작들을 빈으로 등록한다.

 

*RememberMeAuthenticationFilter

 

 

 

AbstractRememberMeServices 를 구현하는 2가지 클래스가 TokenBasedRememberMeServiesPersistenceTokenBasedRememberMeServies다. 

 

TokenBasedRememberMeServies 는 토큰 기반으로 브라우저에 저장한다. 서버에는 남기지 않는다. 이 방식이 기본 설정이다. 아이디:만료시간:비밀번호:인증키 형태로 포맷이 되어있다. 토큰을 탈취당하면 아이디, 만료시간, 비밀번호 등의 정보가 노출되며 탈취 당했는지 여부를 확인하기 힘들다. 탈취를 무효화 시키는 방법은 비밀번호 변경밖에 없으므로 보안에 취약하다.

 

 

PersistenceTokenBasedRememberMeServies 서버에 토큰을 저장해서 이용한다. 보안에 좀 더 유리하다. 포맷은 series:token을 저장한다.(series는 사실상 key값이다.) 유저네임과 만료시간이 노출되지 않는다. series를 사용하는 이유는 다양한 브라우저로 로그인을 했을 떄, 브라우저마다 다른 값으로 인식하기 위해서이다. 브라우저에 로그인 할 때마다 series 값이 바뀌는데, 마지막에 로그인 한 계정의 series가을 기준으로 token값을 검사하여 탈취여부를 판단하므로 좀 더 안전한다.

 

 

* TokenBasedRememberMeServices 실습

 

server:
  port: 9056
  servlet:
    session:
      timeout: 60s

 

토큰 유효시간은 remember-me 실습을 위해 60초로 설정한다.

 

 

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests(request -> request
                    .antMatchers("/").permitAll()
                    .anyRequest().authenticated())
            .formLogin(
                    login -> login.loginPage("/login")
                            .permitAll()
                            .defaultSuccessUrl("/", false)
                            .failureUrl("/login-error")
            )
            .logout(logout ->
                    logout.logoutSuccessUrl("/")
            )
            .exceptionHandling(exception ->
                    exception.accessDeniedPage("/access-denied")
            )
            .rememberMe();
}

 

마지막에 .rememberMe()만 추가해주면 기본설정이 끝난다.

 

 

로그인 화면에서 remember-me를 체크하고 로그인하면, 쿠키에 JESSIONID이외에 remember-me가 추가된것을 알 수 있다.

 

remember-me 쿠키 값을 base64로 decode 한 결과는 다음과 같다.

 

아이디 : 만료시간 : 서명값

 

 

이 토큰이 작동하는 방식을 확인하기 위해서 RememberMeAuthentiationFilter를 살펴본다.

 

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
		throws IOException, ServletException {
	if (SecurityContextHolder.getContext().getAuthentication() != null) {
		this.logger.debug(LogMessage
				.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'"));
		chain.doFilter(request, response);
		return;
	}
	Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
	if (rememberMeAuth != null) {
		// Attempt authenticaton via AuthenticationManager
		try {
			rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
			// Store to SecurityContextHolder
			SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
			onSuccessfulAuthentication(request, response, rememberMeAuth);
			this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
					+ SecurityContextHolder.getContext().getAuthentication() + "'"));
			if (this.eventPublisher != null) {
				this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
						SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
			}
			if (this.successHandler != null) {
				this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
				return;
			}
		}

 

SecurityContextHolder에 인증정보가 없다면 autoLogin을 시도한다. 해당 메서드는 AbstractRememberMeServices 클래스에 정의되어 있다.

 

@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
	String rememberMeCookie = extractRememberMeCookie(request);
	if (rememberMeCookie == null) {
		return null;
	}
	this.logger.debug("Remember-me cookie detected");
	if (rememberMeCookie.length() == 0) {
		this.logger.debug("Cookie was empty");
		cancelCookie(request, response);
		return null;
	}
	try {
		String[] cookieTokens = decodeCookie(rememberMeCookie);
		UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
		this.userDetailsChecker.check(user);
		this.logger.debug("Remember-me cookie accepted");
		return createSuccessfulAuthentication(request, user);
	}

 

autoLogin메서드는 rememberMeCookie를 검사해서 없다면 null을 리턴하지만, 있다면 decodeUserDetails를 획득한다. user는 다음과 같은 cookieTokens의 정보를 가지고 있다.

 

아이디 : 만료시간 : 서명값

 

아이디, 만료시간, 서명값은 String[] cookieTokens = decodeCookie(rememerMeCookie); 로 얻었다.

UserDetails를 리턴하는 유저 정보를 담는 정보들은 processAutoLoginCookie에서 만들어진다.  ( rememberme 토큰 방식에 따라 이 메서드부터 분기가 이루어진다. ) 현재는 processAuttoLoginCookie 메서드는 현재 토큰 서비스가 TokenBasedRememerMeServices로 설정이 되어있어서  해당 클래스에서 수행된다.만약 설정을 바꿔서 PersistenceTokenBasedRememberMeServices 클래스로 rememberme를 구현하면 해당 클래스에서 진행한다.

 

 

TokenBaseRememberMeServices에 정의된 processAutoLoginCookie는 다음과 같다.

 

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
		HttpServletResponse response) {
	if (cookieTokens.length != 3) {
		throw new InvalidCookieException(
				"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
	}
	long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
	if (isTokenExpired(tokenExpiryTime)) {
		throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
				+ "'; current time is '" + new Date() + "')");
	}
	// Check the user exists. Defer lookup until after expiry time checked, to
	// possibly avoid expensive database call.
	UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
	Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
			+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
	// Check signature of token matches remaining details. Must do this after user
	// lookup, as we need the DAO-derived password. If efficiency was a major issue,
	// just add in a UserCache implementation, but recall that this method is usually
	// only called once per HttpSession - if the token is valid, it will cause
	// SecurityContextHolder population, whilst if invalid, will cause the cookie to
	// be cancelled.
	String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
			userDetails.getPassword());
	if (!equals(expectedTokenSignature, cookieTokens[2])) {
		throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
				+ "' but expected '" + expectedTokenSignature + "'");
	}
	return userDetails;
}

 

isTokenExpired()로 토큰 만료시간을 확인하고 UserDetails를 획득한다. getUserDetailsService().loadUserByUsername(cookieTokens[0])를 통해 로그인기록이 있는 아이디와 비밀번호를 얻을 수 있다.(아이디만을 가지고 비밀번호를 얻을 수 있는 loadUserByUsername, 따라서 탈취당하면 계속 탈취한 아이디로 브라우저 사용이 가능하다)

 

 

* loadUserByUsername 구현 방식

 

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
	if (this.delegate != null) {
		return this.delegate.loadUserByUsername(username);
	}
	synchronized (this.delegateMonitor) {
		if (this.delegate == null) {
			for (AuthenticationManagerBuilder delegateBuilder : this.delegateBuilders) {
				this.delegate = delegateBuilder.getDefaultUserDetailsService();
				if (this.delegate != null) {
					break;
				}
			}
			if (this.delegate == null) {
				throw new IllegalStateException("UserDetailsService is required.");
			}
			this.delegateBuilders = null;
		}
	}
	return this.delegate.loadUserByUsername(username);
}

 

getDefaultUserDetailsService()UserDetailsService를 구현한 SpUserService로 인식된다. SpUserServicesloadUserByUsername을 다음과 같이 정의한다.

 

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    return spUserRepository.findUserByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException(username));
}

 

따라서, 마지막 this.delegate.loadUserByUsername(username)username을 이용해서 DB저장소에서 조회해온다.

 

loadUserByUsername으로 불러온 아이디와 비밀번호가 유효한지 토큰을 만들어서 쿠키에 있는 값과 비교한다.

 

protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
	String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
	try {
		MessageDigest digest = MessageDigest.getInstance("MD5");
		return new String(Hex.encode(digest.digest(data.getBytes())));
	}
	catch (NoSuchAlgorithmException ex) {
		throw new IllegalStateException("No MD5 algorithm available!");
	}
}

 

makeTokenSignature로 서명값 만든다.  해당 메서드도 TokenBaseRememberMeServices 클래스에 정의되어 있다. 각 remember-me 구현 방식에 따라 서명을 만드는 방식도 다르다. 만들어진 서명값은 remember-me 토큰을 decode해서 나온 3번째 문자열과 같은 값을 가져야만 최종적으로 인증이 된다.

 

username:tokenExpiryTime:password:getKey()로 조합된 토큰을 생성한다. getKey()값은 현재 서버에서 만들어주는데, 가능한 지정해주는 것이 좋다. 서버를 재시작해서 key값이 바뀌면 그동안 remember-me 설정이 모두 초기화된다. 

 

autoLogin이 정상적으로 끝나면 리턴받은 rememberAuth는 통행증이 발급된다. ( authenticated =true가 된다. )

 

따라서 사용자는 재로그인 없이 바로 권한확인 이후 원하는 페이지로 이동할 수 있다.

 

 

rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);

 

autoLogin으로 리턴받은 rememberMeAuth 인증은 AuthenticationManager를 통해서 다시한번 인증을 완료하다.

 

SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);

 

마무리로 SecurityContextHolder에 저장하면 로그인 상태가 유지된다.

 

세션이 유효하면, remember-me 필터는 거치지 않고 세션 필터만 거친다.

 

필터가 탈취된다면, 탈취한 remember-me 쿠키값을 본인의 브라우저에 저장하고 로그인이 필요한 페이지로 이동을 한다면 이동이 된다. 왜냐하면 makeTokenSignature에서 같은 서명값이 인증되기 때문이다. 비밀번호를 바꾸면 해결할 수 있다. 이것으로 TokenBasedRememberMeServices는 remember-me 쿠키값이 탈취되면 굉장히 보안에 취약하다는 사실을 알 수 있다. 즉, remember-me 토큰에는 많은 로그인한 사용자의 정보들이 고스란히 담겨 있다. remember-me 토큰은 어떤 사용자가 요청하던지 똑같은 방식으로 서명을 만들기 때문에 같은 서명값이 있어서 유효한 사용자로 인식이 된다.

 

토큰이 탈취될때마다 비밀번호를 바꿔주어야 할까?

 

* PersistentTokenBasedRememberMeServices실습

 

PersistentTokenBasedRememberMeServices를 이용한다. 먼저 새로운 토큰 서비스를 만들어야 한다. hello는 서명값을 만들 때 key값으로 고정하며, spUserService는 계정정보를 조회해 올 서비스이고 tokenRepository는 remember-me 토큰 정보를 저장할 저장소이다.

 

@Bean
PersistentTokenBasedRememberMeServices persistentTokenBasedRememberMeServices() {
    return new PersistentTokenBasedRememberMeServices(
            "hello", spUserService, tokenRepository());
}

 

serires와 token을 저장 할   tokenRepository를 빈으로 등록한다.(JPA 저장소를 만들어도 된다)

 

@Bean
PersistentTokenRepository tokenRepository() {
    JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
    repository.setDataSource(dataSource);
    try {
        repository.removeUserTokens("1");
    } catch(Exception ex) {
        repository.setCreateTableOnStartup(true);
    }

    return repository;
}

 

JdbcTokenRepositoryImpl을 저장소로 사용한다. Jdbc기반의 영구적 로그인 토큰 저장소를 구현한 클래스이다.

해당 클래스는 다음과 같이 테이블을 만든다.

 

public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
			+ "token varchar(64) not null, last_used timestamp not null)";

 

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {

 

또한 JdbcDaoSupport를 상속하기 때문에 Datasource를 주입해주어야 한다.

 

 

한가지 주의할 점은 최초에 테이블 생성을 위해 initDao를 실행하며 createTableOnStartup이 true일 때, 테이블을 생성한다.

 

protected void initDao() {
	if (this.createTableOnStartup) {
		getJdbcTemplate().execute(CREATE_TABLE_SQL);
	}
}

 

따라서, 처음 테이블을 만들 때는, 해당 변수를 true로 만들어주어야 하기 때문에 try-catch에서 removeUserTokens()를 이용해 예외를 발생시키고 createTableOnStartup을 true로 만들어준 것이다.

 

.rememberMe(r->
        r.rememberMeServices(persistentTokenBasedRememberMeServices())
           );

 

rememberMe 서비스를 우리가 만든 persistentTokenBasedRememberMeServices()로 바꾼다.

 

 

이번에는 deocde 한 remember-me 토큰이 2개로 나뉜다.

 

 

 

또한 processAutoLoginCookie에서 PeristentTokenBaseRememberMeServices 클래스를 이용한다. 쿠키에 담긴 remember-me 토큰이 2개의 정보로 이루어져 있어서 서명 방식이 달라지기 때문이다. 첫번째 값을 Series, 두번째 값을 token이라고 한다. 

 

@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
		HttpServletResponse response) {
	if (cookieTokens.length != 2) {
		throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
				+ Arrays.asList(cookieTokens) + "'");
	}
	String presentedSeries = cookieTokens[0];
	String presentedToken = cookieTokens[1];
	PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
	if (token == null) {
		// No series match, so we can't authenticate using this cookie
		throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
	}
	// We have a match for this user/series combination
	if (!presentedToken.equals(token.getTokenValue())) {
		// Token doesn't match series value. Delete all logins for this user and throw
		// an exception to warn them.
		this.tokenRepository.removeUserTokens(token.getUsername());
		throw new CookieTheftException(this.messages.getMessage(
				"PersistentTokenBasedRememberMeServices.cookieStolen",
				"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
	}
	if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
		throw new RememberMeAuthenticationException("Remember-me login has expired");
	}
	// Token also matches, so login is valid. Update the token value, keeping the
	// *same* series number.
	this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
			token.getUsername(), token.getSeries()));
	PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
			generateTokenData(), new Date());
	try {
		this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
		addCookie(newToken, request, response);
	}
	catch (Exception ex) {
		this.logger.error("Failed to update token: ", ex);
		throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
	}
	return getUserDetailsService().loadUserByUsername(token.getUsername());
}

 

PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries); 를 통해 Series 값으로 저장소에 저장되어 있는 token값을 조회한다. 

 

if (!presentedToken.equals(token.getTokenValue())) { 를 조사해서 현재 나의 토큰값과 저장소에 있는 토큰값이 같지 않으면, 저장소에 있는 토큰값을 삭제하고 예외를 발생시킨다. this.tokenRepository.removeUserTokens(token.getUsername());

 

if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) 토큰 만료시간을 검사해서 만료되었다면 예외를 발생시킨다.

 

모든 예외상황에서 살아남았다면, series, token, username, Date를 이용해 새로운 토큰을 생성한다. PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), generateTokenData(), new Date());

 

새로 만든 토큰으로 저장소를 업데이트하고 쿠키에도 반영한다.

try { this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate()); 

addCookie(newToken, request, response); }

 

 

로그인을 하면 다음과 같이 쿠키에서 전송한 remeber-me 토큰을 기반으로 series, token와 정보들을 이용해 서버 저장소에 저장한다.

 

 

다음 상황을 가정한다.

remember-me 사용을 체크하고 로그인 한다. 일정 시간 이후 세션을 만료시킨다.(로그인 세션이 만료되는게 핵심) 로그인 세션이 만료된 후, user-page로 이동을 요청하면 세션이 없기 때문에 remeber-me가 감지된다. remember-me가 쿠키 검사를 통해 토큰이 만료가 되었는지, 유효한 사용자인지 검사한다. 유효하다고 판단되면, 정상적으로 user-page로 이동이 되며 동시에 새로운 remember-me 쿠키값으로 업데이트된다.

 

해당 방식은 어떻게 보안이 강력해졌을까?

 

PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);

if (!presentedToken.equals(token.getTokenValue()))

 

series값을 통해 token값을 조회해서 현재 내가 가지고 있는 토큰값과 일치하는지 확인하는 과정이 중요하다. remember-me를 통해 세션을 다시 획득했을 때, token값과 series값은 계속 바뀐다. 따라서 다른 곳에서 탈취했을 때에 token값과 serires 값이 계속 바뀌고 최신화 될 것이다. 본래 계정에서 세션이 만료된 이후, 로그인하거나 로그인이 필요한 서비스를 요청하면 남아있는 remember-me 토큰의 token값과 series값으로 저장소에 저장된 token을 찾아 비교하는 과정에서 정보가 불일칠 할 것이고, 토큰이 탈취된 것으로 간주하여 모든 토큰관련 정보들이 삭제되고 처음부터 다시 로그인해야한다.

 

로그아웃을 하면, remember-me 쿠키는 삭제된다.

반응형

'Spring 정리 > Spring Security' 카테고리의 다른 글

권한체크 오류처리  (0) 2021.10.15
세션관리 ( 동시접속 )  (0) 2021.10.14
UserDetailsService  (0) 2021.10.11
BaiscToken으로 웹/모바일 로그인 인증 구현하기  (0) 2021.10.11
BasicAuthentication 인증  (0) 2021.10.11