Oauth2 Single Login

Oauth2 Single Login

Sometimes we want users to be able to log in to their accounts on only one device (we’re too stingy).

How can we achieve this using springboot security and oauth2?

Note that this article will not take you through the implementation of oauth2 login using spring security, but merely discuss our miserly requirement.

Suppose we have a custom authentication implementation class like this:

 1public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService {
 2
 3	private final static Long TIMEOUT = 10L;
 4
 5	private static final String AUTHORIZATION = "token";
 6
 7	private final RedisTemplate<String, Object> redisTemplate;
 8
 9	@Override
10	public void save(OAuth2Authorization authorization) {
11        // is refresh token mode or code mode
12        // ...
13        // is access token mode
14		if (isAccessToken(authorization)) {
15			OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
16			long between = ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt());
17			redisTemplate.setValueSerializer(RedisSerializer.java());
18			redisTemplate.opsForValue()
19				.set(buildKey(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue()), authorization, between,
20						TimeUnit.SECONDS);
21		}
22	}
23
24	@Override
25	public void remove(OAuth2Authorization authorization) {
26        // is refresh token mode or code mode
27        // ...
28        // is access token mode
29		if (isAccessToken(authorization)) {
30			OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
31			keys.add(buildKey(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue()));
32		}
33		redisTemplate.delete(keys);
34	}
35	@Override
36	@Nullable
37	public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType tokenType) {
38		Assert.hasText(token, "token cannot be empty");
39		Assert.notNull(tokenType, "tokenType cannot be empty");
40		redisTemplate.setValueSerializer(RedisSerializer.java());
41		return (OAuth2Authorization) redisTemplate.opsForValue().get(buildKey(tokenType.getValue(), token));
42	}
43
44    private String buildKey(String type, String id) {
45        return String.format("%s::%s::%s", AUTHORIZATION, type, id);
46    }
47    // ...
48}

It stores the token in redis. The key is in the following format:

1token::access_token::xxxxxxxxxxxxxxxxxxxxxxxx

When a user logs in, findByToken is called by spring security to find out information about the user.

Because we’re stingy, we want a user to log in only once, i.e., only one token per user.

The redis key should no longer use a token, but rather a username;

1token::access_token::that_annoying_user

But how does findByToken find the username based on the token?

With this kind of key, we don’t have an efficient way to find the user out of millions of users.

What about a key like this?

1token::access_token::xxxxxxxxxxxxxxxxxxxxxxxx::that_annoying_user

We got the token, and the username.

Let’s say a user has logged in and got the token `xxxxxxxxxxxxxxxxxxxxxxxx.

He wants to log into our application again, that’s fine. We just find the old token under that username and delete it(them) before logging him in.

1keys token::access_token::*::that_annoying_user

By that, in conjunction with checking for tokens in the app, he will be kicked out of his original login session.

 1public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService {
 2	@Override
 3	public void save(OAuth2Authorization authorization) {
 4        // is refresh token mode or code mode
 5        // ...
 6        // is access token mode
 7		if (isAccessToken(authorization)) {
 8			OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
 9			long between = ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt());
10			redisTemplate.setValueSerializer(RedisSerializer.java());
11
12            // delete old tokens of this user
13            // make sure * or :: or other special character is not allowed in the username
14			Set<String> keys  = redisTemplate.keys(buildKey(OAuth2ParameterNames.ACCESS_TOKEN, "*", authorization.getPrincipalName()));
15			if (! CollectionUtils.isEmpty(keys)) {
16				redisTemplate.delete(keys);
17			}
18
19			redisTemplate.opsForValue()
20				.set(buildKey(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue(), authorization.getPrincipalName()), authorization, between,
21						TimeUnit.SECONDS);
22		}
23	}
24
25	@Override
26	public void remove(OAuth2Authorization authorization) {
27        // is refresh token mode or code mode
28        // ...
29        // is access token mode
30		if (isAccessToken(authorization)) {
31			OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
32			keys.add(buildKey(OAuth2ParameterNames.ACCESS_TOKEN, accessToken.getTokenValue(), authorization.getPrincipalName()));
33		}
34		redisTemplate.delete(keys);
35	}
36
37	@Override
38	@Nullable
39	public OAuth2Authorization findById(String id) {
40		throw new UnsupportedOperationException();
41	}
42
43	@Override
44	@Nullable
45	public OAuth2Authorization findByToken(String token, @Nullable OAuth2TokenType tokenType) {
46		Assert.hasText(token, "token cannot be empty");
47		Assert.notNull(tokenType, "tokenType cannot be empty");
48		redisTemplate.setValueSerializer(RedisSerializer.java());
49
50		// token::access_token::tokenValue::*
51		Set<String> keys = redisTemplate.keys(buildKey(tokenType.getValue(), token, "*"));
52		if (CollectionUtils.isEmpty(keys)) {
53			return null;
54		}
55
56		List<Object> saved = redisTemplate.opsForValue().multiGet(keys);
57		if (CollectionUtils.isEmpty(saved)) {
58			return null;
59		}
60
61		return (OAuth2Authorization) saved.get(0);
62	}
63
64    private String buildKey(String type, String id, String principle) { // 增加了用户名
65        return String.format("%s::%s::%s::%s", AUTHORIZATION, type, id, principle);
66    }
67    // ...
68}