package io.kaif.model.clientapp;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import javax.annotation.Nullable;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import io.kaif.model.account.Account;
import io.kaif.model.account.Authority;
import io.kaif.model.account.Authorization;
import io.kaif.token.Bytes;
/**
* requirement:
* <ul>
* <li>support multiple tokens (different scopes for different devices) at the same time</li>
* <li>invalid if user revoke the client app</li>
* <li>invalid if client app revoked</li>
* <li>invalid if client app reset secret</li>
* <li>account's authorities changed should treat invalid (hard to implement)</li>
* <li>account password changed do not affect this token</li>
* </ul>
* <p>
* because multiple tokens allow to exist, there are some limitations:
* <ul>
* <li>scopes are serialized to token only, it does not persist in database for validation and
* synchronization. If user in his phone grant AppFoo to use two new scopes, in his previous
* tablet, he has to grant again to obtain new scopes </li>
* </ul>
*/
public class ClientAppUserAccessToken implements Authorization, ClientAppAuthorization {
public static Optional<ClientAppUserAccessToken> tryDecode(String rawToken, OauthSecret secret) {
List<byte[]> fields = secret.getCodec().tryDecode(rawToken);
if (fields == null || fields.size() != 5) {
return Optional.empty();
}
try {
return Optional.of(new ClientAppUserAccessToken(//
Bytes.uuidFromBytes(fields.get(0)),
Bytes.longFromBytes(fields.get(1)),
ClientAppScope.tryParse(new String(fields.get(2), Charsets.UTF_8)),
new String(fields.get(3), Charsets.UTF_8),
new String(fields.get(4), Charsets.UTF_8)));
} catch (RuntimeException e) {
return Optional.empty();
}
}
private final long authoritiesBits;
// validate accountId+clientId against ClientAppUser so we can detect if user revoked the
// client app
private final UUID accountId;
private final String clientId;
// validate clientSecret in database so we can detect client app revoked or reset client secret
private final String clientSecret;
// tokenScopes is only for current tokens, this may not the same as ClientAppUser's
// lastGrantedScopes because multiple devices may issue different scopes
// (different access token), but ClientAppUser only store latest issued scopes
private final Set<ClientAppScope> tokenScopes;
private String canonicalScope;
public ClientAppUserAccessToken(UUID accountId,
Set<Authority> authorities,
Set<ClientAppScope> tokenScopes,
String clientId,
String clientSecret) {
this(accountId, Authority.toBits(authorities), tokenScopes, clientId, clientSecret);
}
private ClientAppUserAccessToken(UUID accountId,
long authoritiesBits,
Set<ClientAppScope> tokenScopes,
String clientId,
String clientSecret) {
Preconditions.checkArgument(!tokenScopes.isEmpty(), "token scopes should not empty");
this.authoritiesBits = authoritiesBits;
this.accountId = accountId;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.tokenScopes = tokenScopes;
}
@Override
public String toString() {
return "ClientAppUserAccessToken{" +
"authoritiesBits=" + authoritiesBits +
", accountId=" + accountId +
", clientId='" + clientId + '\'' +
", clientSecret='" + clientSecret + '\'' +
", tokenScopes=" + tokenScopes +
'}';
}
public String encode(Instant expireTime, OauthSecret secret) {
List<byte[]> fields = Arrays.asList(Bytes.uuidToBytes(accountId),
Bytes.longToBytes(authoritiesBits),
ClientAppScope.toCanonicalString(tokenScopes).getBytes(Charsets.UTF_8),
clientId.getBytes(Charsets.UTF_8),
clientSecret.getBytes(Charsets.UTF_8));
return secret.getCodec().encode(expireTime.toEpochMilli(), fields);
}
@Override
public UUID authenticatedId() {
return accountId;
}
@Override
public boolean containsAuthority(Authority authority) {
// if user change authority (for example, ban by sysop), the token is treat invalid
return Authority.bitsContains(authoritiesBits, authority);
}
@Override
public boolean matches(Account account) {
// oauth access token do not honor account's password to revoke token.
// because it rely on revoke of ClientAppUser
return authenticatedId().equals(account.getAccountId())
&& this.authoritiesBits == Authority.toBits(account.getAuthorities());
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
ClientAppUserAccessToken that = (ClientAppUserAccessToken) o;
if (authoritiesBits != that.authoritiesBits) {
return false;
}
if (accountId != null ? !accountId.equals(that.accountId) : that.accountId != null) {
return false;
}
if (clientId != null ? !clientId.equals(that.clientId) : that.clientId != null) {
return false;
}
if (clientSecret != null
? !clientSecret.equals(that.clientSecret)
: that.clientSecret != null) {
return false;
}
return !(tokenScopes != null
? !tokenScopes.equals(that.tokenScopes)
: that.tokenScopes != null);
}
@Override
public int hashCode() {
int result = (int) (authoritiesBits ^ (authoritiesBits >>> 32));
result = 31 * result + (accountId != null ? accountId.hashCode() : 0);
result = 31 * result + (clientId != null ? clientId.hashCode() : 0);
result = 31 * result + (clientSecret != null ? clientSecret.hashCode() : 0);
result = 31 * result + (tokenScopes != null ? tokenScopes.hashCode() : 0);
return result;
}
/**
* validate app revoked or reset secret or user revoked
* <p>
* ClientAppUser may be deleted (revoked) so we accept null value here.
*/
@Override
public boolean validate(@Nullable ClientAppUser clientAppUser) {
return Optional.ofNullable(clientAppUser)
.filter(user -> authenticatedId().equals(user.getAccountId()))
.filter(user -> clientId().equals(user.getClientId()))
.filter(user -> clientSecret.equals(user.getCurrentClientSecret()))
.isPresent();
}
/**
* validate granted scope
*/
@Override
public boolean containsScope(ClientAppScope scope) {
return tokenScopes.contains(scope);
}
@Override
public String clientId() {
return clientId;
}
public String getCanonicalScope() {
return ClientAppScope.toCanonicalString(tokenScopes);
}
}