package io.kaif.service.impl;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import io.kaif.mail.MailAgent;
import io.kaif.model.account.Account;
import io.kaif.model.account.AccountAccessToken;
import io.kaif.model.account.AccountAuth;
import io.kaif.model.account.AccountDao;
import io.kaif.model.account.AccountOnceToken;
import io.kaif.model.account.AccountSecret;
import io.kaif.model.account.AccountStats;
import io.kaif.model.account.Authority;
import io.kaif.model.account.Authorization;
import io.kaif.model.exception.OldPasswordNotMatchException;
import io.kaif.model.exception.RequireCitizenException;
import io.kaif.service.AccountService;
import io.kaif.web.support.AccessDeniedException;
@Service
@Transactional
public class AccountServiceImpl implements AccountService {
private static final Duration ACCOUNT_TOKEN_EXPIRE = Duration.ofDays(30);
private static final Logger logger = LoggerFactory.getLogger(AccountServiceImpl.class);
@Autowired
private AccountDao accountDao;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AccountSecret accountSecret;
@Autowired
private MailAgent mailAgent;
private Clock clock = Clock.systemDefaultZone();
@Override
public Account createViaEmail(String username, String email, String password, Locale locale) {
Preconditions.checkArgument(Account.isValidPassword(password));
Preconditions.checkArgument(Account.isValidUsername(username));
Preconditions.checkNotNull(email);
Instant now = Instant.now(clock);
Account account = accountDao.create(username, email, passwordEncoder.encode(password), now);
sendOnceAccountActivation(account, locale, now);
return account;
}
private void sendOnceAccountActivation(Account account, Locale locale, Instant now) {
AccountOnceToken token = accountDao.createOnceToken(account,
AccountOnceToken.Type.ACTIVATION,
now);
//async send email, no wait
mailAgent.sendAccountActivation(locale, account, token.getToken());
}
@Override
public Optional<Account> findMe(Authorization authorization) {
return accountDao.findById(authorization.authenticatedId());
}
@Override
public Optional<AccountAuth> authenticate(String username, String password) {
return accountDao.findByUsername(username)
.filter(account -> passwordEncoder.matches(password, account.getPasswordHash()))
.map(this::createAccountAuth);
}
private AccountAuth createAccountAuth(Account account) {
Instant now = Instant.now(clock);
Instant expireTime = now.plus(ACCOUNT_TOKEN_EXPIRE);
String accessToken = new AccountAccessToken(account.getAccountId(),
account.getPasswordHash(),
account.getAuthorities()).encode(expireTime, accountSecret);
return new AccountAuth(account.getUsername(),
accessToken,
expireTime.toEpochMilli(),
now.toEpochMilli());
}
/**
* the verification go against database, so it is slow. using {@link
* #tryDecodeAccessToken(String)} if you want faster check.
*/
@Override
public Optional<AccountAccessToken> strongVerifyAccessToken(String rawAccessToken) {
return AccountAccessToken.tryDecode(rawAccessToken, accountSecret)
.filter(auth -> accountDao.strongVerifyAccount(auth).isPresent());
}
@Override
public AccountAuth extendsAccessToken(AccountAccessToken accessToken) {
return Optional.ofNullable(accessToken)
.flatMap(accountDao::strongVerifyAccount)
.map(this::createAccountAuth)
.orElseThrow(() -> new AccessDeniedException("could not extends token because verify failed"));
}
@Override
public boolean isUsernameAvailable(String username) {
return !accountDao.findByUsername(username).isPresent();
}
@Override
public boolean isEmailAvailable(String email) {
return accountDao.isEmailAvailable(email);
}
@Override
public void updateAuthorities(Authorization authorization, EnumSet<Authority> authorities) {
accountDao.strongVerifyAccount(authorization)
.ifPresent(account -> accountDao.updateAuthorities(account, authorities));
}
private void updatePassword(UUID accountId, String password, Locale locale) {
Preconditions.checkArgument(Account.isValidPassword(password));
accountDao.updatePasswordHash(accountId, passwordEncoder.encode(password));
accountDao.findById(accountId)
.ifPresent(account -> mailAgent.sendPasswordWasReset(locale, account));
}
@Override
public boolean activate(String inputOnceToken) {
return accountDao.findOnceToken(inputOnceToken, AccountOnceToken.Type.ACTIVATION)
.filter(token -> token.isValid(Instant.now(clock)))
.map(onceToken -> {
Account account = accountDao.findById(onceToken.getAccountId()).get();
EnumSet<Authority> newAuth = EnumSet.copyOf(account.getAuthorities());
newAuth.add(Authority.CITIZEN);
accountDao.updateAuthorities(account, newAuth);
accountDao.completeOnceToken(onceToken);
return true;
})
.orElse(false);
}
@VisibleForTesting
void setClock(Clock clock) {
this.clock = clock;
}
@Override
public void resendActivation(Authorization authorization, Locale locale) {
accountDao.strongVerifyAccount(authorization)
.ifPresent(account -> sendOnceAccountActivation(account, locale, Instant.now(clock)));
}
@Override
public void sendResetPassword(String username, String email, Locale locale) {
logger.info("begin reset password: {}, {}", username, email);
accountDao.findByUsername(username)
.filter(account -> account.getEmail().equals(email))
.ifPresent(account -> {
AccountOnceToken token = accountDao.createOnceToken(account,
AccountOnceToken.Type.FORGET_PASSWORD,
Instant.now(clock));
mailAgent.sendResetPassword(locale, account, token.getToken());
});
}
@Override
public Optional<AccountOnceToken> findValidResetPasswordToken(String inputOnceToken) {
return accountDao.findOnceToken(inputOnceToken, AccountOnceToken.Type.FORGET_PASSWORD)
.filter(token -> token.isValid(Instant.now(clock)));
}
@Override
public void updatePasswordWithOnceToken(String accountOnceToken, String password, Locale locale) {
findValidResetPasswordToken(accountOnceToken).ifPresent(onceToken -> {
accountDao.completeOnceToken(onceToken);
updatePassword(onceToken.getAccountId(), password, locale);
});
}
@Override
public AccountAuth updateNewPassword(Authorization authorization,
String oldPassword,
String newPassword,
Locale locale) throws OldPasswordNotMatchException {
return accountDao.strongVerifyAccount(authorization)
.filter(account -> passwordEncoder.matches(oldPassword, account.getPasswordHash()))
.flatMap(accountWithOldPassword -> {
updatePassword(authorization.authenticatedId(), newPassword, locale);
//reload with new password
return accountDao.findById(authorization.authenticatedId());
})
.map(this::createAccountAuth)
.orElseThrow(OldPasswordNotMatchException::new);
}
@Override
public Optional<AccountAccessToken> tryDecodeAccessToken(String rawAccountAccessToken) {
return AccountAccessToken.tryDecode(rawAccountAccessToken, accountSecret);
}
@Override
public AccountStats loadAccountStats(String username) {
return accountDao.loadStats(username);
}
@Override
public Account loadAccount(String username) {
return accountDao.loadByUsername(username);
}
@Override
public String updateDescription(Authorization authorization, String description) {
return accountDao.strongVerifyAccount(authorization).flatMap(account -> {
accountDao.updateDescription(account.getAccountId(), description);
return accountDao.findById(authorization.authenticatedId());
}).map(Account::getRenderDescription).orElse("");
}
@Override
public String loadEditableDescription(Authorization authorization) {
return accountDao.strongVerifyAccount(authorization)
.map(Account::getEscapedDescription)
.orElse("");
}
@Override
public void complaintEmail(List<String> emails) {
//TODO handle AWS email complaint
}
@Override
public void muteEmail(List<String> emails) {
//TODO handle AWS permanent Bounced Emails
}
@Override
public AccountOnceToken createOauthDirectAuthorizeToken(Authorization authorization)
throws RequireCitizenException {
Optional<Account> verifiedAccount = accountDao.strongVerifyAccount(authorization);
if (!verifiedAccount.isPresent()) {
throw new AccessDeniedException("invalid access token");
}
if (!verifiedAccount.get().containsAuthority(Authority.CITIZEN)) {
throw new RequireCitizenException();
}
return verifiedAccount.map(account -> accountDao.createOnceToken(account,
AccountOnceToken.Type.OAUTH_DIRECT_AUTHORIZE,
Instant.now(clock))).get();
}
@Override
public Optional<Account> oauthDirectAuthorize(String inputOnceToken) {
return accountDao.findOnceToken(inputOnceToken, AccountOnceToken.Type.OAUTH_DIRECT_AUTHORIZE)
.filter(token -> token.isValid(Instant.now(clock)))
.flatMap(onceToken -> {
accountDao.completeOnceToken(onceToken);
return accountDao.findById(onceToken.getAccountId());
});
}
}