AccountService.java
/*
* Copyright © 2023-2025 The CTAN Team and individual authors
*
* This file is distributed under the 3-clause BSD license.
* See file LICENSE for details.
*/
package org.ctan.site.services.account;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.ctan.site.domain.Gender;
import org.ctan.site.domain.account.Ticket;
import org.ctan.site.domain.account.User;
import org.ctan.site.services.mail.MailException;
import org.ctan.site.services.mail.MailService;
import org.ctan.site.services.mail.MailService.Mail;
import org.ctan.site.stores.AuthorStore;
import org.ctan.site.stores.TicketStore;
import org.ctan.site.stores.UserStore;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
/**
* The class <code>AccountService</code> contains service methods for the CTAN
* site user.
*
* @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
*/
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
@Slf4j
public class AccountService {
/**
* The package transport object.
*/
@AllArgsConstructor
@Builder
@Data
@NoArgsConstructor
public static class PkgTo {
/**
* The field <code>key</code> contains the package key.
*/
private String key;
/**
* The field <code>name</code> contains the package name.
*/
private String name;
/**
* The field <code>version</code> contains the package version.
*/
private String version;
}
/**
* The transport object.
*/
@Builder
@Data
@AllArgsConstructor
public static class To {
/**
* The field <code>value</code> contains the value.
*/
private String value;
/**
* The field <code>msg</code> contains the message.
*/
private String msg;
}
/**
* The user transport object.
*/
@AllArgsConstructor
@Builder
@Data
@NoArgsConstructor
public static class UserTo {
/**
* The field <code>account</code> contains the account name.
*/
private String account;
/**
* The field <code>token</code> contains the user token.
*/
private String token;
/**
* The field <code>refresh</code> contains the refresh token.
*/
private String refresh;
/**
* The field <code>email</code> contains the user's email.
*/
private String email;
/**
* The field <code>name</code> contains the user's full name or
* pseudonym.
*/
private String name;
/**
* The field <code>gender</code> contains the user's gender. It is one
* of the letters m, f, g, x.
*/
private String gender;
/**
* The field <code>city</code> contains the user's location.
*/
private String city;
/**
* The field <code>country</code> contains the user's location.
*/
private String country;
/**
* The field <code>author</code> contains the author key if the user is
* an author or {@link null}.
*/
private String author;
/**
* The field <code>authorCand</code> contains the candidate for an
* author assignment. it is determined by comparing the user's email to
* the author's email.
*/
private String authorCand;
/**
* The field <code>showName</code> contains the indicator whether to
* show the name.
*/
private boolean showName;
/**
* The field <code>showEmail</code> contains the indicator whether to
* show the email.
*/
private boolean showEmail;
/**
* The field <code>htmlEmail</code> contains the indicator whether to
* send HTML email to the user.
*/
private boolean htmlEmail;
/**
* The field <code>selfDescription</code> contains the description of
* the user.
*/
private String selfDescription;
/**
* The field <code>is</code> contains the mapping or roles.
*/
private Map<String, Boolean> is;
/**
* The field <code>packages</code> contains the author's packages if the
* user is author.
*/
private List<PkgTo> packages;
}
/**
* The constant <code>DAYS</code> contains minutes per day.
*/
private static final long DAYS = 60L * 24L;
/**
* The method <code>isEmpty</code> provides means to check whether the
* argument is {@code null} or the empty string.
*
* @param s the reference string
* @return {@code true} if the string is {@code null} or empty
*/
public static final boolean isEmpty(String s) {
return s == null || s.length() == 0;
}
/**
* The field <code>store</code> contains the user repository.
*/
@Getter
private UserStore store;
/**
* The field <code>ticketStore</code> contains the ticket repository.
*/
private TicketStore ticketStore;
/**
* The field <code>mailService</code> contains the mail service.
*/
private MailService mailService;
/**
* The field <code>authorStore</code> contains the author repository.
*/
private AuthorStore authorStore;
/**
* This is the constructor for the class <code>UserService</code>.
*
* @param store the underlying store
* @param ticketStore the ticket store
* @param authorStore the author store
* @param mailService the mail service
*/
@SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
public AccountService(@NonNull UserStore store,
@NonNull TicketStore ticketStore,
@NonNull AuthorStore authorStore,
@NonNull MailService mailService) {
this.store = store;
this.ticketStore = ticketStore;
this.authorStore = authorStore;
this.mailService = mailService;
}
/**
* The method <code>login</code> provides means to login.
*
* @param account the account
* @param passwd the password
* @return the user or {@code null}
*/
public UserTo login(@NonNull String account, @NonNull String passwd) {
var user = store.getByAccount(account);
if (user == null || !user.accept(passwd)) {
return null;
}
String authorCand = null;
var token = JwtManager.createAuth(account);
var refresh = JwtManager.createRefresh(account);
Map<String, Boolean> is = new HashMap<String, Boolean>();
for (var role : user.getRoles()) {
is.put(role.toString().toLowerCase(),
Boolean.TRUE);
}
List<PkgTo> packages = List.of();
var gender = user.getGender().getValue();
var authorKey = user.getAuthorKey();
if (authorKey != null) {
var author = authorStore.getByKey(authorKey);
if (author != null) {
packages = author.getRefs().stream()
.map(p -> PkgTo.builder()
.key(p.getPkg().getKey())
.name(p.getPkg().getName())
.version(p.getPkg().getVers())
.build())
.collect(Collectors.toList());
}
} else {
var author = authorStore.getByEmail(user.getEmail());
if (author != null) {
authorCand = author.getKey();
}
}
return UserTo.builder()
.account(account)
.name(user.getName())
.gender(gender)
.showName(true)
.token(token)
.refresh(refresh)
.email(user.getEmail())
.city(user.getCity())
.country(user.getCountry())
.author(authorKey)
.authorCand(authorCand)
.showName(user.getShowName())
.showEmail(user.getShowEmail())
.htmlEmail(user.getHtmlEmail())
.selfDescription(user.getSelfDescription())
.is(is)
.packages(packages)
.build();
}
/**
* The method <code>passwd</code> provides means to create a ticket and send
* it to the user.
*
* @param account the account name
* @param email the associated email address
* @return a new ticket or <code>null</code> if something fails
*/
public Ticket passwd(String account, String email, String locale) {
if (account == null || email == null) {
return null;
}
if (locale == null) {
locale = "en";
}
User user = store.getByAccount(account);
if (user == null
|| !email.toLowerCase().equals(user.getEmail().toLowerCase())) {
return null;
}
Ticket ticket = ticketStore.createPasswordTicket(account);
try {
mailService.send(Mail.builder()
.locale(locale)
.html(user.getHtmlEmail())
.template("password")
.to(email)
.build());
} catch (MailException e) {
log.error("Failed to send passwd ticket to {1}", email);
}
return ticket;
}
/**
* The method <code>refresh</code> provides means to refresh the access
* token.
*
* @param account the account
* @param refresh the refresh token
* @return the user or {@code null}
*/
public UserTo refresh(@NonNull String account, @NonNull String refresh) {
var user = store.getByAccount(account);
if (user == null
|| !user.getAccount().equals(JwtManager.verifyRefresh(refresh))) {
return null;
}
String authorCand = null;
var token = JwtManager.createAuth(account);
Map<String, Boolean> is = new HashMap<String, Boolean>();
for (var role : user.getRoles()) {
is.put(role.toString().toLowerCase(),
Boolean.TRUE);
}
List<PkgTo> packages = List.of();
var gender = user.getGender().getValue();
var authorKey = user.getAuthorKey();
if (authorKey != null) {
var author = authorStore.getByKey(authorKey);
if (author != null) {
packages = author.getRefs().stream()
.map(p -> PkgTo.builder()
.key(p.getPkg().getKey())
.name(p.getPkg().getName())
.version(p.getPkg().getVers())
.build())
.collect(Collectors.toList());
}
} else {
var author = authorStore.getByEmail(user.getEmail());
if (author != null) {
authorCand = author.getKey();
}
}
return UserTo.builder()
.account(account)
.name(user.getName())
.gender(gender)
.showName(true)
.token(token)
.refresh(refresh)
.email(user.getEmail())
.city(user.getCity())
.country(user.getCountry())
.author(authorKey)
.authorCand(authorCand)
.showName(user.getShowName())
.showEmail(user.getShowEmail())
.htmlEmail(user.getHtmlEmail())
.selfDescription(user.getSelfDescription())
.is(is)
.packages(packages)
.build();
}
/**
* The method <code>registerAccount</code> provides means to start the
* registration process.
*
* @param account the account
* @param email the email
* @param name the name
* @param gender the gender
* @param lang the locale to use
* @return the registered account
*/
public To registerAccount(String account, String email, String name,
Gender gender, String lang) {
if (isEmpty(account)) {
throw new IllegalArgumentException(
"account is marked non-null but is null");
}
if (isEmpty(email)) {
throw new IllegalArgumentException(
"email is marked non-null but is null");
}
validateName(name);
if (store.getByAccount(account) != null) {
return new To(null, "user-exists");
}
if (lang == null) {
lang = "en";
}
var user = User.builder()
.account(account)
.email(email)
.name(name)
.gender(gender)
.accountLocked(true)
.dateCreated(LocalDateTime.now())
.password("NEW")
.build();
user = store.save(user);
var ticket = ticketStore.createRegisterTicket(account);
try {
mailService.send(Mail.builder()
.to(email)
.template("register")
.model(Map.of("user", user, "ticket", ticket))
.html(false)
.locale(lang)
.build());
} catch (MailException e) {
ticketStore.remove(ticket);
return new To(null, "mail-failed");
}
return new To(ticket.getKey(), null);
}
/**
* The method <code>set</code> provides means to set a single attribute of
* the user and save the result.
*
* @param account the account
* @param attribute the attribute
* @param value the value
*/
public boolean set(@NonNull String account, @NonNull String attribute,
@NonNull String value) {
var user = store.getByAccount(account);
if (user == null) {
throw new IllegalArgumentException();
}
switch (attribute) {
case "email":
user.setEmail(value);
break;
case "gender":
user.setGender(Gender.of(value));
break;
case "htmlEmail":
user.setHtmlEmail("true".equals(value));
break;
case "city":
user.setCity(value);
break;
case "country":
user.setCountry(value);
break;
case "name":
if ("".equals(value)) {
throw new IllegalArgumentException();
}
user.setName(value);
break;
case "description":
user.setSelfDescription(value);
break;
case "passwd":
if (value.length() <= 3) {
throw new IllegalArgumentException();
}
user.setHashedPassword(value);
break;
case "showEmail":
user.setShowEmail("true".equals(value));
break;
case "showName":
user.setShowName("true".equals(value));
break;
default:
throw new IllegalArgumentException();
}
return store.save(user) != null;
}
/**
* The method <code>setPasswdFromTicket</code> provides means to set the
* password from a ticket.
*
* @param ticket the ticket
* @param password the password
*
* @return {@code true} iff the password could be stored
*/
public boolean setPasswdFromTicket(String ticket, String password) {
var t = verifyTicket(ticket);
if (t == null) {
return false;
}
var user = store.getByAccount(t.getAccount());
ticketStore.remove(t);
user.setHashedPassword(password);
return store.save(user) != null;
}
/**
* The method <code>setPassword</code> provides means to set the password
* from a user.
*
* @param account the user name
* @param password the new password
* @return {@code true} iff the password has been changed
*/
public boolean setPassword(@NonNull String account,
@NonNull String password) {
var user = store.getByAccount(account);
if (user == null) {
return false;
}
if (password.length() < 3) {
return false;
}
user.setHashedPassword(password);
return store.save(user) != null;
}
/**
* The method <code>validateName</code> provides means to check the real
* name for a valid value.
*
* @param name the real name
*/
private void validateName(String name) {
if (isEmpty(name)) {
throw new IllegalArgumentException(
"name is marked non-null but is null");
}
name = name.replaceAll("\\s+", " ").trim();
if (name.indexOf(' ') < 0) {
throw new IllegalArgumentException("unexpected name");
}
var p = name.replaceAll("[a-z]+[A-Z]", "#");
if (p.indexOf('#') >= 0) {
throw new IllegalArgumentException("unexpected name");
}
}
/**
* The method <code>verifyTicket</code> provides means to check that a
* ticket exists and is not expired.
*
* @param key the ticket key
* @return the ticket or {@code null}
*/
public Ticket verifyTicket(String key) {
var ticket = ticketStore.get(key);
if (ticket == null) {
return null;
}
if (ticket.isExpired(7L * DAYS)) {
ticketStore.remove(ticket);
return null;
}
return ticket;
}
}