package io.kaif.service.impl;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import javax.annotation.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.google.common.annotations.VisibleForTesting;
import io.kaif.model.account.Account;
import io.kaif.model.account.AccountDao;
import io.kaif.model.account.Authority;
import io.kaif.model.account.Authorization;
import io.kaif.model.clientapp.ClientApp;
import io.kaif.model.clientapp.ClientAppDao;
import io.kaif.model.clientapp.ClientAppScope;
import io.kaif.model.clientapp.ClientAppUser;
import io.kaif.model.clientapp.ClientAppUserAccessToken;
import io.kaif.model.clientapp.GrantCode;
import io.kaif.model.clientapp.OauthSecret;
import io.kaif.model.exception.ClientAppMaxException;
import io.kaif.oauth.OauthAccessTokenDto;
import io.kaif.oauth.Oauths;
import io.kaif.service.AccountService;
import io.kaif.service.ClientAppService;
import io.kaif.web.support.AccessDeniedException;
@Service
@Transactional
public class ClientAppServiceImpl implements ClientAppService {
private static final Duration GRANT_CODE_DURATION = Duration.ofMinutes(10);
//we are just follow github, grant long term access token instead of requiring refresh periodically
private static final Duration ACCESS_TOKEN_EXPIRE_DURATION = Duration.ofDays(20 * 365);
private static final Duration DEBUG_ACCESS_TOKEN_EXPIRE_DURATION = Duration.ofHours(1);
@Autowired
private AccountDao accountDao;
@Autowired
private ClientAppDao clientAppDao;
@Autowired
private AccountService accountService;
@Autowired
private OauthSecret oauthSecret;
@Override
public ClientApp create(Authorization creator,
String name,
String description,
String callbackUri) throws ClientAppMaxException {
Account account = verifyDeveloper(creator);
if (listClientApps(account).size() >= ClientApp.MAX_NO_OF_APPS) {
throw new ClientAppMaxException(ClientApp.MAX_NO_OF_APPS);
}
return clientAppDao.createApp(account, name, description, callbackUri, Instant.now());
}
private Account verifyDeveloper(Authorization creator) {
return accountDao.strongVerifyAccount(creator)
.filter(a -> a.containsAuthority(Authority.CITIZEN))
.orElseThrow(() -> new AccessDeniedException("no authority on client app."));
}
@Override
public ClientApp loadClientAppWithoutCache(String clientId) {
return clientAppDao.loadAppWithoutCache(clientId);
}
@Override
public List<ClientApp> listClientApps(Authorization creator) {
return clientAppDao.listAppOrderByTime(creator.authenticatedId());
}
@Override
public void update(Authorization creator,
String clientId,
String name,
String description,
String callbackUri) {
ClientApp clientApp = verifyClientAppForOwner(creator, clientId);
clientAppDao.updateAppInformation(clientApp.withName(name)
.withDescription(description)
.withCallbackUri(callbackUri));
}
@Override
public Optional<ClientApp> verifyRedirectUri(String clientId, String redirectUri) {
return clientAppDao.findApp(clientId).filter(app -> app.validateRedirectUri(redirectUri));
}
@Override
public String directGrantCode(String oauthDirectAuthorize,
String clientId,
String scope,
String redirectUri) throws AccessDeniedException {
//before grant code step, redirect uri, client id, scope all should be verified
ClientApp clientApp = verifyRedirectUri(clientId,
redirectUri).orElseThrow(() -> new IllegalStateException("invalid clientId and redirectUri"));
Set<ClientAppScope> clientAppScopes = ClientAppScope.tryParse(scope);
if (clientAppScopes.isEmpty()) {
throw new IllegalStateException("invalid scope");
}
Account account = accountService.oauthDirectAuthorize(oauthDirectAuthorize)
.orElseThrow(() -> new AccessDeniedException("direct authorize failed"));
return new GrantCode(account.getAccountId(),
clientApp.getClientId(),
clientApp.getClientSecret(),
redirectUri,
clientAppScopes).encode(Instant.now().plus(GRANT_CODE_DURATION), oauthSecret);
}
/**
* if failed to create access token, mostly cause by grant code validation failed
* the server should response error=invalid_grant
*/
@Override
public OauthAccessTokenDto createOauthAccessTokenByGrantCode(String code,
String clientId,
String redirectUri) throws AccessDeniedException {
return verifyRedirectUri(clientId, redirectUri).flatMap(clientApp -> {
//TODO 1. code should only used once
//TODO 2. if code reused, revoke previous issued access token by same code
return GrantCode.tryDecode(code, oauthSecret)
.filter(grantCode -> grantCode.matches(clientApp, redirectUri))
.map(grantCode -> createOauthAccessToken(clientApp,
grantCode.getAccountId(),
grantCode.getScopes(),
ACCESS_TOKEN_EXPIRE_DURATION));
}).orElseThrow(() -> new AccessDeniedException("invalid grant for oauth access token"));
}
/**
* back door to create ClientAppUser and accessToken directly, for ease of testing
*/
@VisibleForTesting
OauthAccessTokenDto createOauthAccessToken(ClientApp clientApp,
UUID accountId,
Set<ClientAppScope> scopes,
Duration tokenExpireDuration) {
Account account = accountDao.findById(accountId).get();
clientAppDao.mergeClientAppUser(account, clientApp, scopes, Instant.now());
ClientAppUserAccessToken accessToken = new ClientAppUserAccessToken(account.getAccountId(),
account.getAuthorities(),
scopes,
clientApp.getClientId(),
clientApp.getClientSecret());
String encodedToken = accessToken.encode(Instant.now().plus(tokenExpireDuration), oauthSecret);
return new OauthAccessTokenDto(encodedToken,
accessToken.getCanonicalScope(),
Oauths.DEFAULT_TOKEN_TYPE);
}
/**
* unlike AccountAccessToken, which have two variations (one is in memory verify, the other is
* hit db for strong verify). ClientAppAccessToken are always check against db, but with one
* minute local caching window. So in worst cases, user or app revoke, the token require wait up
* to one minute to take effect when kaif deploy to multiple servers.
*/
@Override
public Optional<ClientAppUserAccessToken> verifyAccessToken(@Nullable String rawAccessToken) {
return ClientAppUserAccessToken.tryDecode(rawAccessToken, oauthSecret).filter(token -> {
Optional<ClientAppUser> clientAppUser = clientAppDao.findClientAppUserWithCache(token.authenticatedId(),
token.clientId());
return token.validate(clientAppUser.orElse(null));
});
}
@Override
public List<ClientAppUser> listGrantedAppUsers(Authorization authorization) {
return clientAppDao.listUsers(authorization.authenticatedId());
}
@Override
public void resetClientAppSecret(Authorization creator, String clientId) {
ClientApp clientApp = verifyClientAppForOwner(creator, clientId);
clientAppDao.updateAppSecret(clientApp.withResetSecret());
}
private ClientApp verifyClientAppForOwner(Authorization creator, String clientId) {
Account account = verifyDeveloper(creator);
ClientApp clientApp = clientAppDao.loadAppWithoutCache(clientId);
if (!clientApp.isOwner(account)) {
throw new AccessDeniedException("not client app owner");
}
return clientApp;
}
@Override
public void revokeApp(Authorization user, String clientId) {
clientAppDao.deleteClientAppUser(user.authenticatedId(), clientId);
}
@Override
public boolean validateApp(String clientId, String clientSecret) {
return clientAppDao.findApp(clientId)
.filter(app -> app.getClientSecret().equals(clientSecret))
.isPresent();
}
@Override
public String generateDebugAccessToken(Authorization creator, String clientId) {
ClientApp clientApp = verifyClientAppForOwner(creator, clientId);
return createOauthAccessToken(clientApp,
creator.authenticatedId(),
EnumSet.allOf(ClientAppScope.class),
DEBUG_ACCESS_TOKEN_EXPIRE_DURATION).getAccessToken();
}
@Override
public List<ClientApp> listGrantedApps(Authorization user) {
return clientAppDao.listAppsByUser(user.authenticatedId());
}
}