/*******************************************************************************
* Copyright (c) 2015 IBH SYSTEMS GmbH.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* IBH SYSTEMS GmbH - initial API and implementation
*******************************************************************************/
package org.eclipse.packagedrone.sec.service.apm;
import static org.eclipse.packagedrone.repo.utils.Tokens.createToken;
import static org.eclipse.packagedrone.sec.service.common.Users.hashIt;
import java.text.MessageFormat;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.eclipse.packagedrone.repo.MetaKey;
import org.eclipse.packagedrone.sec.CreateUser;
import org.eclipse.packagedrone.sec.DatabaseUserInformation;
import org.eclipse.packagedrone.sec.UserDetails;
import org.eclipse.packagedrone.sec.UserInformation;
import org.eclipse.packagedrone.sec.UserStorage;
import org.eclipse.packagedrone.sec.service.LoginException;
import org.eclipse.packagedrone.sec.service.UserService;
import org.eclipse.packagedrone.sec.service.apm.model.UserEntity;
import org.eclipse.packagedrone.sec.service.apm.model.UserModel;
import org.eclipse.packagedrone.sec.service.apm.model.UserStorageModelProvider;
import org.eclipse.packagedrone.sec.service.apm.model.UserWriteModel;
import org.eclipse.packagedrone.sec.service.common.SecurityMailService;
import org.eclipse.packagedrone.sec.service.password.BadPasswordException;
import org.eclipse.packagedrone.sec.service.password.PasswordChecker;
import org.eclipse.packagedrone.storage.apm.StorageManager;
import org.eclipse.packagedrone.storage.apm.StorageRegistration;
import org.eclipse.packagedrone.utils.scheduler.ScheduledTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DatabaseUserService implements UserService, UserStorage, ScheduledTask
{
private static final MetaKey MODEL_KEY = new MetaKey ( "sec", "users" );
private final static Logger logger = LoggerFactory.getLogger ( DatabaseUserService.class );
private static final long MIN_EMAIL_DELAY = TimeUnit.MINUTES.toMillis ( 5 );
private PasswordChecker passwordChecker;
private SecurityMailService mailService;
private StorageManager storageManager;
private StorageRegistration handle;
public void setSecurityMailService ( final SecurityMailService mailService )
{
this.mailService = mailService;
}
public void setPasswordChecker ( final PasswordChecker passwordChecker )
{
this.passwordChecker = passwordChecker;
}
public void setStorageManager ( final StorageManager storageManager )
{
this.storageManager = storageManager;
}
public void start ()
{
this.handle = this.storageManager.registerModel ( 1_000, MODEL_KEY, new UserStorageModelProvider () );
}
public void stop ()
{
if ( this.handle != null )
{
this.handle.unregister ();
this.handle = null;
}
}
@Override
public List<DatabaseUserInformation> list ( final int position, final int size )
{
return this.storageManager.accessCall ( MODEL_KEY, UserModel.class, users -> {
final List<DatabaseUserInformation> list = users.listAll ();
final int end = Math.min ( position + size, list.size () );
return users.listAll ().subList ( position, end );
} );
}
@Override
public DatabaseUserInformation createUser ( final CreateUser data, final boolean emailVerified )
{
return this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
if ( !emailVerified )
{
// check only for external signup
checkPassword ( data.getPassword () );
}
if ( data.getEmail () != null && users.findByEmail ( data.getEmail () ).isPresent () )
{
throw new IllegalArgumentException ( String.format ( "A user with the e-mail '%s' already exists.", data.getEmail () ) );
}
final Date now = new Date ();
final UserEntity user = new UserEntity ();
user.setId ( UUID.randomUUID ().toString () );
user.setEmail ( data.getEmail () );
user.setName ( data.getName () );
user.setRegistrationDate ( now );
user.setLocked ( false );
applyPassword ( user, data.getPassword () );
final String token;
if ( emailVerified )
{
token = null;
user.setEmailVerified ( true );
}
else
{
user.setEmailVerified ( false );
token = applyNewEmailToken ( now, user );
}
// update
if ( token != null )
{
this.mailService.sendVerifyEmail ( data.getEmail (), user.getId (), token );
}
users.putUser ( user );
return new DatabaseUserInformation ( user.getId (), null, user.getRoles (), Helper.toDetails ( user ) );
} );
}
@Override
public DatabaseUserInformation getUserDetails ( final String userId )
{
return this.storageManager.accessCall ( MODEL_KEY, UserModel.class, users -> users.getUser ( userId ).orElse ( null ) );
}
@Override
public DatabaseUserInformation updateUser ( final String userId, final UserDetails data )
{
return this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.getCheckedUser ( userId );
user.setEmail ( data.getEmail () );
user.setName ( data.getName () );
user.setRoles ( new HashSet<> ( data.getRoles () ) );
users.putUser ( user );
return new DatabaseUserInformation ( userId, null, data.getRoles (), Helper.toDetails ( user ) );
} );
}
@Override
public String verifyEmail ( final String userId, final String token )
{
return this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.getUser ( userId ).orElse ( null );
if ( user == null )
{
return "User not found";
}
if ( user.isLocked () )
{
return "User is locked";
}
if ( user.getEmailToken () == null || user.isEmailVerified () )
{
// we are already verified
return null;
}
final String salt = user.getEmailTokenSalt ();
final String hashedToken = hashIt ( salt, token );
if ( hashedToken.equals ( user.getEmailToken () ) )
{
user.setEmailVerified ( true );
user.setEmailToken ( null );
user.setEmailTokenSalt ( null );
user.setEmailTokenDate ( null );
users.putUser ( user );
return null;
}
return "It may be that you clicked on a verification link which was either expired or superseeded by another e-mail request.";
} );
}
@Override
public DatabaseUserInformation getUserDetailsByEmail ( final String email )
{
return this.storageManager.accessCall ( MODEL_KEY, UserModel.class, users -> users.findUserByEmail ( email ).orElse ( null ) );
}
@Override
public String reRequestEmail ( final String email )
{
return this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.findByEmail ( email ).orElse ( null );
if ( user == null )
{
return "User not found";
}
if ( user.isLocked () )
{
return "User is locked";
}
if ( user.getEmailToken () == null || user.isEmailVerified () )
{
// we are already verified
return "E-Mail is already verified";
}
if ( isTooSoon ( user.getEmailTokenDate () ) )
{
return MessageFormat.format ( "An e-mail verification was requested at {0,time}. Please wait until {1,time} before requesting the next one!", user.getEmailTokenDate (), nextMailSlot ( user.getEmailTokenDate () ) );
}
final String token = applyNewEmailToken ( new Date (), user );
users.putUser ( user );
this.mailService.sendVerifyEmail ( user.getEmail (), user.getId (), token );
return null;
} );
}
@Override
public String resetPassword ( final String email )
{
return this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.findByEmail ( email ).orElse ( null );
if ( user == null )
{
return "No account for this e-mail address.";
}
if ( !user.isEmailVerified () )
{
return "The e-mail address for this account is not verified.";
}
if ( isTooSoon ( user.getEmailTokenDate () ) )
{
return MessageFormat.format ( "A password reset e-mail was requested at {0,time}. Please wait until {1,time} before requesting the next one!", user.getEmailTokenDate (), nextMailSlot ( user.getEmailTokenDate () ) );
}
if ( user.isLocked () )
{
// we silently fail, since this would give out information about the user's state
this.mailService.sendEmail ( user.getEmail (), "Password reset request", "lockedUser", null );
return null;
}
final String resetToken = createToken ( 64 );
final String resetTokenSalt = createToken ( 32 );
final String resetTokenHash = hashIt ( resetTokenSalt, resetToken );
user.setEmailTokenSalt ( resetTokenSalt );
user.setEmailTokenDate ( new Date () );
user.setEmailToken ( resetTokenHash );
users.putUser ( user );
// we don't touch the password for now, could be anybody
this.mailService.sendResetEmail ( email, resetToken );
return null;
} );
}
@Override
public void changePassword ( final String email, final String token, final String password )
{
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> {
logger.debug ( "Process password change - email: {}, token: {}, password: {}", email, token, password != null ? "***" : null );
final UserEntity user = users.findByEmail ( email ).orElse ( null );
if ( user == null )
{
throw new RuntimeException ( "User not found" );
}
// validate token
final String salt = user.getEmailTokenSalt ();
if ( salt == null )
{
throw new RuntimeException ( "No token" );
}
final String hashedToken = hashIt ( salt, token );
if ( !hashedToken.equals ( user.getEmailToken () ) )
{
throw new RuntimeException ( "Invalid token" );
}
// check for "locked" after the token was validated
if ( user.isLocked () )
{
throw new RuntimeException ( "User is locked" );
}
checkPassword ( password );
applyPassword ( user, password );
user.setEmailToken ( null );
user.setEmailTokenDate ( null );
user.setEmailTokenSalt ( null );
users.putUser ( user );
} );
}
@Override
public void updatePassword ( final String userId, final String currentPassword, final String newPassword )
{
logger.debug ( "Process password update - userId: {}, currentPassword: {}, newPassword: {}", userId, currentPassword != null ? "***" : null, newPassword != null ? "***" : null );
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.getCheckedUser ( userId );
if ( user.isLocked () )
{
throw new RuntimeException ( "User is locked" );
}
final String currentSalt = user.getPasswordSalt ();
final String currentHash = user.getPasswordHash ();
checkPassword ( newPassword );
if ( currentPassword != null && currentSalt != null && currentHash != null )
{
final String checkHash = hashIt ( currentSalt, currentPassword );
if ( !currentHash.equals ( checkHash ) )
{
throw new RuntimeException ( "Current password is incorrect" );
}
}
applyPassword ( user, newPassword );
users.putUser ( user );
} );
}
protected void setUserLocked ( final UserWriteModel users, final String userId, final boolean value )
{
final UserEntity user = users.getCheckedUser ( userId );
user.setLocked ( value );
users.putUser ( user );
}
@Override
public void lockUser ( final String userId )
{
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> setUserLocked ( users, userId, true ) );
}
@Override
public void unlockUser ( final String userId )
{
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> setUserLocked ( users, userId, false ) );
}
@Override
public void deleteUser ( final String userId )
{
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> users.removeUser ( userId ) );
}
private static class LoginResult
{
UserInformation userInformation;
LoginException exception;
}
@Override
public UserInformation checkCredentials ( final String username, final String credentials, final boolean rememberMe ) throws LoginException
{
final LoginResult result = loginAndRememberMe ( username, credentials, rememberMe );
if ( result == null )
{
return null;
}
if ( result.exception != null )
{
throw result.exception;
}
return result.userInformation;
}
private LoginResult loginAndRememberMe ( final String username, final String credentials, final boolean rememberMe )
{
final LoginResult result = this.storageManager.modifyCall ( MODEL_KEY, UserWriteModel.class, users -> {
final UserEntity user = users.findByEmail ( username ).orElse ( null );
if ( user == null )
{
// let the next one try
return null;
}
boolean valid = false;
// check for remember me token
if ( user.getRememberMeTokenHash () != null && user.getRememberMeTokenSalt () != null )
{
final String tokenHash = hashIt ( user.getRememberMeTokenSalt (), credentials );
if ( tokenHash.equals ( user.getRememberMeTokenHash () ) )
{
valid = true;
}
}
// check for password
if ( user.getPasswordSalt () != null && user.getPasswordHash () != null )
{
final String credHash = hashIt ( user.getPasswordSalt (), credentials );
if ( credHash.equals ( user.getPasswordHash () ) )
{
valid = true;
}
}
if ( !valid )
{
// no valid credentials, let other services try
return null;
}
final LoginResult loginResult = new LoginResult ();
// only fail _after_ the password has been checked, so we should be sure it is the user
loginResult.exception = validateUserAfterLogin ( user );
// handle remember me
String rememberMeToken;
if ( rememberMe )
{
rememberMeToken = createToken ( 128 );
final String tokenSalt = createToken ( 32 );
final String tokenHash = hashIt ( tokenSalt, rememberMeToken );
user.setRememberMeTokenHash ( tokenHash );
user.setRememberMeTokenSalt ( tokenSalt );
users.putUser ( user );
}
else
{
rememberMeToken = null;
}
loginResult.userInformation = new DatabaseUserInformation ( user.getId (), rememberMeToken, user.getRoles (), Helper.toDetails ( user ) );
// we made it
return loginResult;
} );
return result;
}
@Override
public UserInformation refresh ( final UserInformation user )
{
return getUserDetails ( user.getId () );
}
@Override
public boolean hasUserBase ()
{
return this.storageManager.accessCall ( MODEL_KEY, UserModel.class, users -> !users.isEmpty () );
}
private void checkPassword ( final String password )
{
try
{
this.passwordChecker.checkPassword ( password );
}
catch ( final BadPasswordException e )
{
throw new RuntimeException ( e );
}
}
private static Date nextMailSlot ( final Date date )
{
return new Date ( date.getTime () + MIN_EMAIL_DELAY );
}
private static boolean isTooSoon ( final Date date )
{
if ( date == null )
{
return false;
}
return System.currentTimeMillis () - date.getTime () < MIN_EMAIL_DELAY;
}
private void applyPassword ( final UserEntity user, final String password )
{
if ( password == null || password.isEmpty () )
{
return;
}
final String salt = createToken ( 32 );
final String passwordHash = hashIt ( salt, password );
user.setPasswordSalt ( salt );
user.setPasswordHash ( passwordHash );
}
protected String applyNewEmailToken ( final Date now, final UserEntity user )
{
final String token = createToken ( 32 );
final String tokenSalt = createToken ( 32 );
final String tokenHash = hashIt ( tokenSalt, token );
user.setEmailToken ( tokenHash );
user.setEmailTokenSalt ( tokenSalt );
user.setEmailTokenDate ( now );
return token;
}
protected LoginException validateUserAfterLogin ( final UserEntity user )
{
if ( user.isLocked () )
{
return new LoginException ( "User is locked" );
}
if ( !user.isEmailVerified () )
{
return new LoginException ( "E-mail not verified" );
}
return null;
}
@Override
public void run () throws Exception
{
this.storageManager.modifyRun ( MODEL_KEY, UserWriteModel.class, users -> {
final Date timeout = new Date ( System.currentTimeMillis () - getTimeout () );
final Collection<UserEntity> updates = new LinkedList<> ();
final Collection<String> removals = new LinkedList<> ();
for ( final UserEntity user : users.asCollection () )
{
if ( user.getEmailTokenDate () == null || user.getEmailTokenDate ().before ( timeout ) )
{
continue;
}
// process timeout
if ( user.isEmailVerified () )
{
user.setEmailToken ( null );
user.setEmailTokenDate ( null );
user.setEmailTokenSalt ( null );
updates.add ( user );
}
else
{
// delete
removals.add ( user.getId () );
}
}
updates.forEach ( users::putUser );
removals.forEach ( users::removeUser );
} );
}
private long getTimeout ()
{
return TimeUnit.HOURS.toMillis ( 1 );
}
}