package com.psddev.cms.db; import java.io.IOException; import java.io.StringWriter; import java.nio.ByteBuffer; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.servlet.http.HttpServletRequest; import com.google.common.io.BaseEncoding; import com.psddev.cms.tool.CmsTool; import com.psddev.cms.tool.Dashboard; import com.psddev.cms.tool.DashboardContainer; import com.psddev.cms.tool.SearchResultSelection; import com.psddev.cms.tool.ToolEntityTfaRequired; import com.psddev.cms.tool.ToolPageContext; import com.psddev.dari.db.Application; import com.psddev.dari.db.Database; import com.psddev.dari.db.Query; import com.psddev.dari.db.Record; import com.psddev.dari.db.State; import com.psddev.dari.util.CompactMap; import com.psddev.dari.util.HtmlWriter; import com.psddev.dari.util.ImageEditor; import com.psddev.dari.util.ObjectUtils; import com.psddev.dari.util.Password; import com.psddev.dari.util.Settings; import com.psddev.dari.util.StorageItem; import com.psddev.dari.util.StringUtils; /** User that uses the CMS and other related tools. */ @ToolUi.DefaultSortField("name") @ToolUi.IconName("object-toolUser") @Record.BootstrapPackages("Users and Roles") @Record.BootstrapTypeMappable(groups = Content.class, uniqueKey = "email") public class ToolUser extends Record implements Managed, ToolEntity { private static final long TOKEN_CHECK_EXPIRE_MILLISECONDS = 30000L; @Indexed @ToolUi.DefaultSearchResult @ToolUi.Note("If left blank, the user will have full access to everything.") private ToolRole role; @Indexed @Required private String name; @Indexed(unique = true) private String email; @Indexed(unique = true) @ToolUi.Placeholder(dynamicText = "${content.email}") private String username; @ToolUi.FieldDisplayType("password") private String password; private StorageItem avatar; @DisplayName("Dashboard") @ToolUi.Tab("Dashboard") private DashboardContainer dashboardContainer; @Deprecated @DisplayName("Legacy Dashboard") @ToolUi.Tab("Dashboard") @ToolUi.Note("Deprecated. Please use the Dashboard field above instead.") @Embedded private Dashboard dashboard; @ToolUi.Hidden private Date passwordChangedDate; private Locale locale = Locale.getDefault(); @ToolUi.FieldDisplayType("timeZone") private String timeZone; @ToolUi.Hidden private UUID currentPreviewId; @ToolUi.TestSms private String phoneNumber; private Set<NotificationMethod> notifyVia; @ToolUi.Hidden private Map<String, Object> settings; @ToolUi.Hidden private Site currentSite; @ToolUi.Hidden private Schedule currentSchedule; @ToolUi.Tab("Advanced") @DisplayName("Two-Factor Authentication Required?") @ToolUi.Placeholder("Default") private ToolEntityTfaRequired tfaRequired; @ToolUi.Hidden private boolean tfaEnabled; @ToolUi.Hidden private String totpSecret; @ToolUi.Hidden private long lastTotpCounter; @Indexed @ToolUi.Hidden private String totpToken; @ToolUi.Hidden private long totpTokenTime; @Indexed(unique = true) @ToolUi.Hidden private Set<String> contentLocks; @ToolUi.Hidden private Set<UUID> automaticallySavedDraftIds; @ToolUi.Hidden private boolean external; @ToolUi.Hidden @ToolUi.FieldDisplayType("toolUserSavedSearches") private Map<String, String> savedSearches; @ToolUi.Placeholder("All Contents") private InlineEditing inlineEditing; @ToolUi.Tab("Advanced") private boolean returnToDashboardOnSave; @ToolUi.Tab("Advanced") private boolean returnToDashboardOnWorkflow; @ToolUi.Tab("Advanced") private boolean disableNavigateAwayAlert; @ToolUi.Tab("Advanced") private boolean disableCodeMirrorRichTextEditor; @ToolUi.Tab("Advanced") private boolean disableWorkInProgress; @ToolUi.Note("Force the user to change the password on next log in.") private boolean changePasswordOnLogIn; @Indexed @ToolUi.Hidden private String changePasswordToken; @ToolUi.Hidden private long changePasswordTokenTime; @Deprecated @ToolUi.Placeholder("Default") @ToolUi.Tab("Advanced") @ToolUi.Values({ "v2", "v3" }) private String theme; @ToolUi.Hidden private SearchResultSelection currentSearchResultSelection; @ToolUi.Hidden private Map<String, String> searchViews; @ToolUi.Hidden private Map<String, List<String>> searchResultFieldsByTypeId; @Indexed @Embedded @ToolUi.Hidden private List<LoginToken> loginTokens; @ToolUi.Hidden private UUID compareId; /** Returns the role. */ public ToolRole getRole() { return role; } /** Sets the role. */ public void setRole(ToolRole role) { this.role = role; } /** Returns the name. */ public String getName() { return name; } /** Sets the name. */ public void setName(String name) { this.name = name; } /** Returns the email. */ public String getEmail() { return email; } /** Sets the email. */ public void setEmail(String email) { this.email = email; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } /** Returns the password. */ public Password getPassword() { return Password.valueOf(password); } /** Sets the password. */ public void setPassword(Password password) { this.password = password.toString(); this.passwordChangedDate = new Date(); } public Date getPasswordChangedDate() { return passwordChangedDate; } public StorageItem getAvatar() { return avatar; } public void setAvatar(StorageItem avatar) { this.avatar = avatar; } public DashboardContainer getDashboardContainer() { if (dashboardContainer == null && dashboard != null) { DashboardContainer.OneOff oneOff = new DashboardContainer.OneOff(); oneOff.setDashboard(dashboard); return oneOff; } else { return dashboardContainer; } } public void setDashboardContainer(DashboardContainer dashboardContainer) { this.dashboardContainer = dashboardContainer; } @Deprecated public Dashboard getDashboard() { return dashboard; } @Deprecated public void setDashboard(Dashboard dashboard) { this.dashboard = dashboard; } /** * @return the user's locale. */ public Locale getLocale() { return locale; } /** * Sets the user's locale. * @param locale the locale. */ public void setLocale(Locale locale) { this.locale = locale; } /** * Returns the time zone. */ public String getTimeZone() { return timeZone; } /** * Sets the time zone. */ public void setTimeZone(String timeZone) { this.timeZone = timeZone; } /** * Finds the device that the user is using in the given {@code request}. * * @param request Can't be {@code null}. * @return Never {@code null}. */ public ToolUserDevice findOrCreateCurrentDevice(HttpServletRequest request) { String userAgent = request.getHeader("User-Agent"); if (userAgent == null) { userAgent = "Unknown Device"; } ToolUserDevice device = null; for (ToolUserDevice d : Query .from(ToolUserDevice.class) .where("user = ?", this) .selectAll()) { if (userAgent.equals(d.getUserAgent())) { device = d; break; } } if (device == null) { device = new ToolUserDevice(); device.setUser(this); device.setUserAgent(userAgent); device.save(); } return device; } /** * Finds the most recent device that the user was using. * * @return May be {@code null}. */ public ToolUserDevice findRecentDevice() { ToolUserDevice device = null; for (ToolUserDevice d : Query .from(ToolUserDevice.class) .where("user = ?") .selectAll()) { if (device == null || device.findLastAction() == null || (d.findLastAction() != null && d.findLastAction().getTime() > device.findLastAction().getTime())) { device = d; } } return device; } /** * Saves the given {@code action} performed by this user in the device * associated with the given {@code request}. * * @param request Can't be {@code null}. * @param content If {@code null}, does nothing. */ public void saveAction(HttpServletRequest request, Object content) { if (content == null || ObjectUtils.to(boolean.class, request.getParameter("_mirror"))) { return; } ToolUserAction action = new ToolUserAction(); StringBuilder url = new StringBuilder(); String query = request.getQueryString(); url.append(request.getServletPath()); if (query != null) { url.append('?'); url.append(query); } action.setContentId(State.getInstance(content).getId()); action.setUrl(url.toString()); findOrCreateCurrentDevice(request).saveAction(action); } public UUID getCurrentPreviewId() { return currentPreviewId; } public void setCurrentPreviewId(UUID currentPreviewId) { this.currentPreviewId = currentPreviewId; } public String getPhoneNumber() { return phoneNumber; } public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } /** * @return Never {@code null}. */ public Set<NotificationMethod> getNotifyVia() { if (notifyVia == null) { notifyVia = new LinkedHashSet<NotificationMethod>(); } return notifyVia; } /** * @param notifyVia May be {@code null} to clear the set. */ public void setNotifyVia(Set<NotificationMethod> notifyVia) { this.notifyVia = notifyVia; } /** * @deprecated No replacement. */ @Deprecated public Set<Notification> getNotifications() { return new LinkedHashSet<Notification>(); } /** * @deprecated No replacement. */ @Deprecated public void setNotifications(Set<Notification> notifications) { } /** Returns the settings. */ public Map<String, Object> getSettings() { if (settings == null) { settings = new LinkedHashMap<String, Object>(); } return settings; } /** Sets the settings. */ public void setSettings(Map<String, Object> settings) { this.settings = settings; } /** * Returns the ToolUser's current {@link Site} or the first accessible Site. * @throws IllegalStateException if the user doesn't have access to any Sites. * @return the ToolUser's current Site or null if the ToolUser is using the Global Site. */ public Site getCurrentSite() { if ((currentSite == null && hasPermission("site/global")) || (currentSite != null && hasPermission(currentSite.getPermissionId()))) { return currentSite; } else { for (Site s : Site.Static.findAll()) { if (hasPermission(s.getPermissionId())) { return s; } } if (hasPermission("site/global")) { return null; } throw new IllegalStateException("No accessible site!"); } } /** * Returns a {@code List<Site>} to which the ToolUser has access. The ToolUser's * {@link #getCurrentSite() current Site} and the Global Site are excluded from * this list. * @return a {@code List<Site>} to which the ToolUser has access. */ public List<Site> findOtherAccessibleSites() { Site currentSite = getCurrentSite(); return Site.Static.findAll() .stream() .filter((Site site) -> hasPermission(site.getPermissionId()) && !ObjectUtils.equals(currentSite, site)) .collect(Collectors.toList()); } public void setCurrentSite(Site site) { this.currentSite = site; } public Schedule getCurrentSchedule() { return currentSchedule; } public void setCurrentSchedule(Schedule currentSchedule) { this.currentSchedule = currentSchedule; } public boolean isTfaEnabled() { return tfaEnabled; } public void setTfaEnabled(boolean tfaEnabled) { this.tfaEnabled = tfaEnabled; } public boolean isTfaRequired() { if (tfaRequired != null) { return ToolEntityTfaRequired.REQUIRED.equals(tfaRequired); } else if (getRole() != null) { return getRole().isTfaRequired(); } else { return Application.Static.getInstance(CmsTool.class).isTfaRequired(); } } public void setTfaRequired(ToolEntityTfaRequired tfaRequired) { this.tfaRequired = tfaRequired; } public String getTotpSecret() { return totpSecret; } public String getTotpToken() { return totpToken; } public byte[] getTotpSecretBytes() { return BaseEncoding.base32().decode(getTotpSecret()); } public void setTotpSecretBytes(byte[] totpSecretBytes) { this.totpSecret = BaseEncoding.base32().encode(totpSecretBytes); } public void setTotpToken(String totpToken) { this.totpToken = totpToken; this.totpTokenTime = System.currentTimeMillis(); } private static final String TOTP_ALGORITHM = "HmacSHA1"; private static final long TOTP_INTERVAL = 30000L; private int getTotpCode(long counter) { try { Mac mac = Mac.getInstance(TOTP_ALGORITHM); mac.init(new SecretKeySpec(getTotpSecretBytes(), TOTP_ALGORITHM)); byte[] hash = mac.doFinal(ByteBuffer.allocate(8).putLong(counter).array()); int offset = hash[hash.length - 1] & 0xf; int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); return binary % 1000000; } catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } catch (InvalidKeyException error) { throw new IllegalStateException(error); } } public boolean verifyTotp(int code) { long counter = System.currentTimeMillis() / TOTP_INTERVAL - 2; for (long end = counter + 5; counter < end; ++ counter) { if (counter > lastTotpCounter && code == getTotpCode(counter)) { lastTotpCounter = counter; save(); return true; } } return false; } private Set<String> createLocks(String idPrefix) { long counter = System.currentTimeMillis() / 10000; Set<String> locks = new HashSet<String>(); locks.add(idPrefix + counter); locks.add(idPrefix + (counter + 1)); return locks; } /** * Tries to lock the content with the given {@code id} for exclusive * writes. * * @param id Can't be {@code null}. * @return The tool user that holds the lock. Never {@code null}. */ public ToolUser lockContent(UUID id) { if (Query.from(CmsTool.class).first().isDisableContentLocking()) { return this; } String idPrefix = id.toString() + '/'; long counter = System.currentTimeMillis() / 10000; String currentCounter = String.valueOf(counter); String nextCounter = String.valueOf(counter + 1); String currentLock = idPrefix + currentCounter; String nextLock = idPrefix + nextCounter; ToolUser user = Query .from(ToolUser.class) .where("_id != ?", this) .and("contentLocks = ?", Arrays.asList(currentLock, nextLock)) .first(); if (user != null) { return user; } Set<String> newLocks = contentLocks != null ? contentLocks : new HashSet<String>(); Set<String> oldLocks = new HashSet<String>(newLocks); for (Iterator<String> i = newLocks.iterator(); i.hasNext();) { String lock = i.next(); if (lock.startsWith(idPrefix) || !(lock.endsWith(currentCounter) || lock.endsWith(nextCounter))) { i.remove(); } } newLocks.add(currentLock); newLocks.add(nextLock); if (!newLocks.equals(oldLocks)) { contentLocks = newLocks; save(); } return this; } /** * Releases the exclusive write lock on the content with the given * {@code id}. * * @param id Can't be {@code null}. */ public void unlockContent(UUID id) { String idPrefix = id.toString() + '/'; Set<String> locks = createLocks(idPrefix); ToolUser user = Query .from(ToolUser.class) .where("_id != ?", this) .and("contentLocks = ?", locks) .first(); if (user != null) { for (Iterator<String> i = user.contentLocks.iterator(); i.hasNext();) { if (i.next().startsWith(idPrefix)) { i.remove(); } } user.save(); } } /** * Sets the specified {@link SearchResultSelection} as the {@link ToolUser}'s current selection. The current selection * is used to provide contextual {@link com.psddev.cms.tool.SearchResultAction}s. If the ToolUser already has a current selection, * the selection will replaced and if the user has not saved the existing selection, it will be cleared and deleted. * @param selection the {@link SearchResultSelection} to set as current for this {@link ToolUser} * @return the current selection for this {@link ToolUser} after the deactivation of the specified selection. */ public SearchResultSelection activateSelection(SearchResultSelection selection) { SearchResultSelection currentSelection = getCurrentSearchResultSelection(); // If the current selection is not saved, clear it. if (currentSelection != null && !isSavedSearchResultSelection(currentSelection)) { currentSelection.clear(); currentSelection.delete(); } // Set the current selection setCurrentSearchResultSelection(selection); save(); return selection; } /** * Resets this {@link ToolUser}s current {@link SearchResultSelection} to a new instance. If the specified SearchResultSelection * is saved for this ToolUser, it is replaced with a new SearchResultSelection, otherwise, the existing one is cleared. * @param selection the SearchResultSelection to deactivate * @return the current selection for this {@link ToolUser} after the deactivation of the specified selection. */ public SearchResultSelection deactivateSelection(SearchResultSelection selection) { return deactivateSelection(selection, false); } /** * Resets this {@link ToolUser}s current {@link SearchResultSelection} to a new instance. If the specified SearchResultSelection * is saved for this ToolUser, it is replaced with a new SearchResultSelection, otherwise, the existing one is cleared. * If checked is true, the specified SearchResultSelection must be the same as the ToolUser's current selection, otherwise an * {@link IllegalStateException} will be thrown. * @param selection the SearchResultSelection to deactivate * @param checked indicates whether to require that the specified {@link SearchResultSelection} is the same as the {@link ToolUser}'s current selection. default: {@code false} * @return the current selection for this {@link ToolUser} after the deactivation of the specified selection. */ public SearchResultSelection deactivateSelection(SearchResultSelection selection, boolean checked) { // Throw an exception if this is a checked invocation. if (checked && selection != null && getCurrentSearchResultSelection() != null && !selection.equals(getCurrentSearchResultSelection())) { throw new IllegalStateException("The specified selection is not active for this user!"); } // Reset the current selection. return resetCurrentSelection(); } /** * Returns {@code true} if the specified {@link SearchResultSelection} is saved for this {@link ToolUser}. * @param selection the {@link SearchResultSelection} to check * @return {@code true} if the specified {@link SearchResultSelection} is saved for this {@link ToolUser}. */ public boolean isSavedSearchResultSelection(SearchResultSelection selection) { return selection != null && !ObjectUtils.isBlank(selection.getName()) && (selection.getEntities().contains(this) || (getRole() != null && selection.getEntities().contains(getRole()))); } /** * Clears the {@link ToolUser}'s current {@link SearchResultSelection} if it is not saved, otherwise creates a new one with * this {@link ToolUser} as the default accessible {@link ToolEntity}. * @return the {@link ToolUser}'s current {@link SearchResultSelection} after the reset has been performed. */ public SearchResultSelection resetCurrentSelection() { if (getCurrentSearchResultSelection() != null && !isSavedSearchResultSelection(getCurrentSearchResultSelection())) { getCurrentSearchResultSelection().clear(); } else { SearchResultSelection selection = new SearchResultSelection(); selection.getEntities().add(this); selection.save(); setCurrentSearchResultSelection(selection); save(); } return getCurrentSearchResultSelection(); } public Set<UUID> getAutomaticallySavedDraftIds() { if (automaticallySavedDraftIds == null) { automaticallySavedDraftIds = new LinkedHashSet<UUID>(); } return automaticallySavedDraftIds; } public void setAutomaticallySavedDraftIds(Set<UUID> draftIds) { this.automaticallySavedDraftIds = draftIds; } public boolean isExternal() { return external; } public void setExternal(boolean external) { this.external = external; } public Map<String, String> getSavedSearches() { if (savedSearches == null) { savedSearches = new CompactMap<String, String>(); } return savedSearches; } public void setSavedSearches(Map<String, String> savedSearches) { this.savedSearches = savedSearches; } public InlineEditing getInlineEditing() { return inlineEditing; } public void setInlineEditing(InlineEditing inlineEditing) { this.inlineEditing = inlineEditing; } public boolean isReturnToDashboardOnSave() { return returnToDashboardOnSave; } public void setReturnToDashboardOnSave(boolean returnToDashboardOnSave) { this.returnToDashboardOnSave = returnToDashboardOnSave; } public boolean isReturnToDashboardOnWorkflow() { return returnToDashboardOnWorkflow; } public void setReturnToDashboardOnWorkflow(boolean returnToDashboardOnWorkflow) { this.returnToDashboardOnWorkflow = returnToDashboardOnWorkflow; } /** * @return the disableNavigateAwayAlert */ public boolean isDisableNavigateAwayAlert() { return disableNavigateAwayAlert; } /** * @param disableNavigateAwayAlert the disableNavigateAwayAlert to set */ public void setDisableNavigateAwayAlert(boolean disableNavigateAwayAlert) { this.disableNavigateAwayAlert = disableNavigateAwayAlert; } public boolean isDisableCodeMirrorRichTextEditor() { return disableCodeMirrorRichTextEditor; } public void setDisableCodeMirrorRichTextEditor(boolean disableCodeMirrorRichTextEditor) { this.disableCodeMirrorRichTextEditor = disableCodeMirrorRichTextEditor; } public boolean isDisableWorkInProgress() { return disableWorkInProgress; } public void setDisableWorkInProgress(boolean disableWorkInProgress) { this.disableWorkInProgress = disableWorkInProgress; } public boolean isChangePasswordOnLogIn() { return changePasswordOnLogIn; } public void setChangePasswordOnLogIn(boolean changePasswordOnLogIn) { this.changePasswordOnLogIn = changePasswordOnLogIn; } public String getChangePasswordToken() { return changePasswordToken; } public void setChangePasswordToken(String changePasswordToken) { this.changePasswordToken = changePasswordToken; this.changePasswordTokenTime = changePasswordToken == null ? 0L : System.currentTimeMillis(); } @Deprecated public String getTheme() { return theme; } @Deprecated public void setTheme(String theme) { this.theme = theme; } public SearchResultSelection getCurrentSearchResultSelection() { return currentSearchResultSelection; } public void setCurrentSearchResultSelection(SearchResultSelection currentSearchResultSelection) { this.currentSearchResultSelection = currentSearchResultSelection; } public Map<String, String> getSearchViews() { if (searchViews == null) { searchViews = new CompactMap<>(); } return searchViews; } public void setSearchViews(Map<String, String> searchViews) { this.searchViews = searchViews; } public Map<String, List<String>> getSearchResultFieldsByTypeId() { if (searchResultFieldsByTypeId == null) { searchResultFieldsByTypeId = new CompactMap<>(); } return searchResultFieldsByTypeId; } public void setSearchResultFieldsByTypeId(Map<String, List<String>> searchResultFieldsByTypeId) { this.searchResultFieldsByTypeId = searchResultFieldsByTypeId; } public void updatePassword(Password password) { setPassword(password); setChangePasswordToken(null); } /** * Returns {@code true} if this user is allowed access to the * resources identified by the given {@code permissionId}. */ public boolean hasPermission(String permissionId) { ToolRole role = getRole(); return role != null ? role.hasPermission(permissionId) : true; } /** * Returns {@code true} if forgot paassword email was never sent * or was sent before the given {@code interval} in minutes. */ public boolean isAllowedToRequestForgotPassword(long interval) { return changePasswordTokenTime + interval * 60L * 1000L < System.currentTimeMillis(); } @Override protected void beforeSave() { String email = getEmail(); String username = getUsername(); if (ObjectUtils.isBlank(email)) { if (ObjectUtils.isBlank(username)) { throw new IllegalArgumentException("Email or username is required!"); } else if (username.contains("@")) { setEmail(username); setUsername(null); } } } @Override public Iterable<? extends ToolUser> getUsers() { return Collections.singleton(this); } public String generateLoginToken() { LoginToken loginToken = new LoginToken(); getLoginTokens().add(loginToken); save(); return loginToken.getToken(); } public void refreshLoginToken(String token) { Iterator<LoginToken> iter = getLoginTokens().iterator(); while (iter.hasNext()) { LoginToken loginToken = iter.next(); if (loginToken.getToken().equals(token)) { loginToken.refreshToken(); } else if (!loginToken.isValid()) { iter.remove(); } } save(); } public void removeLoginToken(String token) { LoginToken loginToken = getLoginToken(token); if (loginToken != null) { getLoginTokens().remove(loginToken); save(); } } public LoginToken getLoginToken(String token) { for (LoginToken loginToken : getLoginTokens()) { if (loginToken.getToken().equals(token) && loginToken.isValid()) { return loginToken; } } return null; } public List<LoginToken> getLoginTokens() { if (loginTokens == null) { loginTokens = new ArrayList<LoginToken>(); } return loginTokens; } public void setLoginTokens(List<LoginToken> loginTokens) { this.loginTokens = loginTokens; } public UUID getCompareId() { return compareId; } public void setCompareId(UUID compareId) { this.compareId = compareId; } public Object createCompareObject() { UUID compareId = getCompareId(); if (compareId != null) { Object compareObject = Query.fromAll().where("_id = ?", compareId).first(); if (compareObject != null) { if (compareObject instanceof Draft) { return ((Draft) compareObject).recreate(); } else if (compareObject instanceof History) { return ((History) compareObject).getObject(); } else { return compareObject; } } } return null; } public String createAvatarHtml() { StringWriter string = new StringWriter(); HtmlWriter html = new HtmlWriter(string); try { String name = getName(); html.writeStart("span", "class", "ToolUserAvatar", "title", name); { StringBuilder initials = new StringBuilder(); if (StringUtils.isBlank(name)) { initials.append("?"); } else { String[] nameParts = name.trim().split("\\s+"); for (int i = 0, length = nameParts.length; i < length; ++i) { char initial = nameParts[i].charAt(0); if (Character.isLetter(initial)) { initials.append(initial); if (initials.length() >= 2) { break; } } } } html.writeHtml(initials); StorageItem avatar = getAvatar(); if (avatar != null) { html.writeElement("img", "src", ImageEditor.Static.resize(ImageEditor.Static.getDefault(), avatar, null, 100, 100).getPublicUrl()); } else { String email = getEmail(); if (!ObjectUtils.isBlank(email)) { String hash = StringUtils.hex(StringUtils.md5(email.trim().toLowerCase(Locale.ENGLISH))); html.writeElement("img", "src", StringUtils.addQueryParameters( "https://www.gravatar.com/avatar/" + hash, "s", 100, "d", "blank")); } } } html.writeEnd(); return string.toString(); } catch (IOException error) { throw new IllegalStateException(error); } } @Override public String createManagedEditUrl(ToolPageContext page) { return page.cmsUrl("/admin/users.jsp", "id", getId()); } public static class LoginToken extends Record { @Indexed private String token; private Long expireTimestamp; public LoginToken() { this.token = UUID.randomUUID().toString(); refreshToken(); } public String getToken() { return token; } public Long getExpireTimestamp() { return expireTimestamp; } public void refreshToken() { refreshTokenIfNecessary(); } public boolean refreshTokenIfNecessary() { long sessionTimeout = Settings.getOrDefault(long.class, "cms/tool/sessionTimeout", 0L); if (sessionTimeout == 0L && (this.expireTimestamp == null || this.expireTimestamp != 0L)) { this.expireTimestamp = 0L; return true; } // Only refresh if the expireTimestamp is empty or token was issued over TOKEN_CHECK_EXPIRE_MILLISECONDS ago. if (sessionTimeout != 0L && (this.expireTimestamp == null || this.expireTimestamp == 0L || (this.expireTimestamp - sessionTimeout) + TOKEN_CHECK_EXPIRE_MILLISECONDS < System.currentTimeMillis())) { this.expireTimestamp = System.currentTimeMillis() + sessionTimeout; return true; } return false; } public boolean isValid() { if (getExpireTimestamp() == null) { return false; } if (getExpireTimestamp() == 0L) { return true; } return getExpireTimestamp() > System.currentTimeMillis(); } } public static final class Static { private Static() { } public static ToolUser getByTotpToken(String totpToken) { ToolUser user = Query.from(ToolUser.class).option(Database.DISABLE_FUNNEL_CACHE_QUERY_OPTION, true).where("totpToken = ?", totpToken).first(); return user != null && user.totpTokenTime + 60000 > System.currentTimeMillis() ? user : null; } public static ToolUser getByChangePasswordToken(String changePasswordToken) { ToolUser user = Query.from(ToolUser.class).option(Database.DISABLE_FUNNEL_CACHE_QUERY_OPTION, true).where("changePasswordToken = ?", changePasswordToken).first(); long expiration = Settings.getOrDefault(long.class, "cms/tool/changePasswordTokenExpirationInHours", 24L) * 60L * 60L * 1000L; return user != null && user.changePasswordTokenTime + expiration > System.currentTimeMillis() ? user : null; } public static ToolUser getByToken(String token) { ToolUser user = Query.from(ToolUser.class).option(Database.DISABLE_FUNNEL_CACHE_QUERY_OPTION, true).where("loginTokens/token = ?", token).first(); return user != null && user.getLoginToken(token) != null ? user : null; } } public enum InlineEditing { ONLY_MAIN_CONTENT("Only Main Content"), DISABLED("Disabled"); private final String label; private InlineEditing(String label) { this.label = label; } @Override public String toString() { return label; } } }