package com.intrbiz.bergamot.ui.router; import java.io.IOException; import java.util.concurrent.TimeUnit; import org.apache.log4j.Logger; import com.intrbiz.Util; import com.intrbiz.accounting.Accounting; import com.intrbiz.balsa.engine.route.Router; import com.intrbiz.balsa.engine.security.AuthenticationResponse; import com.intrbiz.balsa.engine.security.challenge.U2FAuthenticationChallenge; import com.intrbiz.balsa.engine.security.credentials.BackupCodeCredentials; import com.intrbiz.balsa.engine.security.credentials.GenericAuthenticationToken; import com.intrbiz.balsa.engine.security.credentials.HOTPCredentials; import com.intrbiz.balsa.engine.security.credentials.PasswordCredentials; import com.intrbiz.balsa.engine.security.credentials.U2FAuthenticationChallengeResponse; import com.intrbiz.balsa.engine.security.method.AuthenticationMethod; import com.intrbiz.balsa.error.BalsaConversionError; import com.intrbiz.balsa.error.BalsaSecurityException; import com.intrbiz.balsa.error.BalsaValidationError; import com.intrbiz.balsa.metadata.WithDataAdapter; import com.intrbiz.bergamot.accounting.model.LoginAccountingEvent; import com.intrbiz.bergamot.data.BergamotDB; import com.intrbiz.bergamot.model.APIToken; import com.intrbiz.bergamot.model.Contact; import com.intrbiz.bergamot.model.GlobalSetting; import com.intrbiz.bergamot.model.Site; import com.intrbiz.bergamot.ui.BergamotApp; import com.intrbiz.bergamot.ui.security.password.check.BadPassword; import com.intrbiz.bergamot.ui.security.password.check.PasswordCheckEngine; import com.intrbiz.crypto.cookie.CryptoCookie; import com.intrbiz.metadata.Any; import com.intrbiz.metadata.AsBoolean; import com.intrbiz.metadata.Catch; import com.intrbiz.metadata.CheckStringLength; import com.intrbiz.metadata.CoalesceMode; import com.intrbiz.metadata.Cookie; import com.intrbiz.metadata.Get; import com.intrbiz.metadata.IsaInt; import com.intrbiz.metadata.Order; import com.intrbiz.metadata.Param; import com.intrbiz.metadata.Post; import com.intrbiz.metadata.Prefix; import com.intrbiz.metadata.RequireAuthenticating; import com.intrbiz.metadata.RequirePrincipal; import com.intrbiz.metadata.RequireValidAccessTokenForURL; import com.intrbiz.metadata.RequireValidPrincipal; import com.intrbiz.metadata.Template; import com.yubico.u2f.data.messages.AuthenticateRequestData; import com.yubico.u2f.data.messages.AuthenticateResponse; @Prefix("/") @Template("layout/single") public class LoginRouter extends Router<BergamotApp> { private Logger logger = Logger.getLogger(LoginRouter.class); private Accounting accounting = Accounting.create(LoginRouter.class); @Get("/login") public void login(@Param("redirect") String redirect, @Cookie("bergamot.auto.login") String autoAuthToken) throws Exception { if (! Util.isEmpty(autoAuthToken)) { // try the given auth token and assert the contact has ui.access permission AuthenticationResponse authResp = tryAuthenticate(new GenericAuthenticationToken(autoAuthToken)); if (authResp != null) { // record the token in the session for removal on logout sessionVar("bergamot.auto.login", autoAuthToken); // complete the login this.completeLogin(authResp, redirect); return; } } // should we redirect to the first install try (BergamotDB db = BergamotDB.connect()) { GlobalSetting firstInstall = db.getGlobalSetting(GlobalSetting.NAME.FIRST_INSTALL); if (firstInstall == null) { // redirect to the first install helper redirect("/global/install/"); return; } } // show the login page var("redirect", redirect); var("username", cookie("bergamot.username")); encode("login/login"); } @Post("/login") @RequireValidAccessTokenForURL() @WithDataAdapter(BergamotDB.class) public void doLogin(BergamotDB db, @Param("username") String username, @Param("password") String password, @Param("redirect") String redirect, @Param("remember_me") @AsBoolean(defaultValue = false, coalesce = CoalesceMode.ALWAYS) Boolean rememberMe) throws Exception { logger.info("Login: " + username); AuthenticationResponse authResp = authenticate(new PasswordCredentials.Simple(username, password)); // set a cookie of the username, to remember the user cookie().name("bergamot.username").value(username).path(path("/login")).expiresAfter(90, TimeUnit.DAYS).httpOnly().set(); // if remember me is selected then push a long term auth cookie if (rememberMe) { // get the contact which has authenticated Contact contact = authResp.getPrincipal(); // generate the token String autoAuthToken = app().getSecurityEngine().generatePerpetualAuthenticationTokenForPrincipal(contact); // store the token db.setAPIToken(new APIToken(autoAuthToken, contact, "Auto login for " + request().getRemoteAddress())); // set the cookie cookie() .name("bergamot.auto.login") .value(autoAuthToken) .path(path("/login")) .expiresAfter(90, TimeUnit.DAYS) .httpOnly() .secure(request().isSecure()) .set(); // record the token in the session for removal on logout sessionVar("bergamot.auto.login", autoAuthToken); } // complete the login this.completeLogin(authResp, redirect); } private void completeLogin(AuthenticationResponse authResp, String redirect) throws Exception { if (authResp.isComplete()) { Contact contact = authResp.getPrincipal(); // accounting this.accounting.account(new LoginAccountingEvent(contact.getSiteId(), contact.getId(), request().getServerName(), null, balsa().session().id(), false, true, request().getRemoteAddress())); // do we need to force a password change if (contact.isForcePasswordChange()) { var("redirect", redirect); var("forced", true); encode("login/force_change_password"); } else { // redirect redirect(Util.isEmpty(redirect) ? "/" : path(redirect)); } } else { // trigger the two factor authentication U2FAuthenticationChallenge u2fChallenge = (U2FAuthenticationChallenge) authResp.getChallenges().get(AuthenticationMethod.U2F); if (u2fChallenge != null) { // encode the U2F login view var("redirect", redirect); var("u2fauthenticate", u2fChallenge.getChallenge()); encode("login/u2f_authenticate"); } else { this.startHOTPAuthentication(redirect); } } } @Any("/start-hotp-authentication") @RequireAuthenticating() public void startHOTPAuthentication(@Param("redirect") String redirect) throws Exception { // start the HOTP login var("redirect", redirect); encode("login/hotp_authenticate"); } @Post("/finish-hotp-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() @WithDataAdapter(BergamotDB.class) public void finishHOTPAuthentication(BergamotDB db, @Param("code") @IsaInt(min = 0, max = 999999, mandatory = true) int code, @Param("redirect") String redirect) throws Exception { AuthenticationResponse authResp = authenticate(new HOTPCredentials.Simple(code)); this.completeLogin(authResp, redirect); } @Catch(BalsaValidationError.class) @Catch(BalsaConversionError.class) @Catch(BalsaSecurityException.class) @Post("/finish-hotp-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() public void finishHOTPAuthenticationError(@Param("redirect") String redirect) throws Exception { // error during HOTP var("redirect", redirect); var("failed", true); encode("login/hotp_authenticate"); } @Any("/start-backup-code-authentication") @RequireAuthenticating() public void startBackupCodeAuthentication() throws Exception { encode("login/backup_code_authenticate"); } @Post("/finish-backup-code-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() @WithDataAdapter(BergamotDB.class) public void finishBackupCodeAuthentication(BergamotDB db, @Param("code") String code) throws Exception { AuthenticationResponse authResp = authenticate(new BackupCodeCredentials.Simple(code)); this.completeLogin(authResp, path("/profile/")); } @Catch(BalsaValidationError.class) @Catch(BalsaConversionError.class) @Catch(BalsaSecurityException.class) @Post("/finish-backup-code-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() public void finishBackupCodeAuthenticationError(@Param("redirect") String redirect) throws Exception { // error during backup code var("failed", true); encode("login/backup_code_authenticate"); } @Post("/finish-u2f-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() @WithDataAdapter(BergamotDB.class) public void finishU2FAuthentication(BergamotDB db, @Param("u2f-authenticate-request") String u2fAuthenticateRequest, @Param("u2f-authenticate-response") String u2fAuthenticateResponse, @Param("redirect") String redirect) throws Exception { AuthenticateRequestData challenge = AuthenticateRequestData.fromJson(u2fAuthenticateRequest); AuthenticateResponse response = AuthenticateResponse.fromJson(u2fAuthenticateResponse); AuthenticationResponse authResp = authenticate(new U2FAuthenticationChallengeResponse(challenge, response)); this.completeLogin(authResp, redirect); } @Catch(BalsaValidationError.class) @Catch(BalsaConversionError.class) @Catch(BalsaSecurityException.class) @Post("/finish-u2f-authentication") @RequireAuthenticating() @RequireValidAccessTokenForURL() public void finishU2FAuthenticationError(@Param("redirect") String redirect) throws Exception { U2FAuthenticationChallenge u2fChallenge = (U2FAuthenticationChallenge) authenticationState().challenges().get(AuthenticationMethod.U2F); // encode the U2F login view var("failed", true); var("redirect", redirect); var("u2fauthenticate", u2fChallenge.getChallenge()); encode("login/u2f_authenticate"); } @Get("/change-password") @RequirePrincipal() public void changePassword(@Param("redirect") String redirect) { var("redirect", redirect); var("forced", false); encode("login/force_change_password"); } @Post("/force-change-password") @RequirePrincipal() @RequireValidAccessTokenForURL() public void changePassword(@Param("password") @CheckStringLength(mandatory = true, min = 8) String password, @Param("confirm_password") @CheckStringLength(mandatory = true, min = 8) String confirmPassword, @Param("redirect") String redirect) throws IOException { try { // verify the password == confirm_password if (! password.equals(confirmPassword)) throw new BadPassword("mismatch"); // enforce the default password policy PasswordCheckEngine.getDefaultInstance().check(password); // update the password Contact contact = currentPrincipal(); logger.info("Processing password change for " + contact.getEmail() + " => " + contact.getSiteId() + "::" + contact.getId()); try (BergamotDB db = BergamotDB.connect()) { contact.hashPassword(password); db.setContact(contact); } logger.info("Password change complete for " + contact.getEmail() + " => " + contact.getSiteId() + "::" + contact.getId()); // redirect redirect(Util.isEmpty(redirect) ? "/" : path(redirect)); } catch (BadPassword e) { var("redirect", redirect); var("forced", true); var("error", e.getMessage()); encode("login/force_change_password"); } } @Catch(BalsaValidationError.class) @Catch(BalsaConversionError.class) @Catch(BalsaSecurityException.class) @Order() @Post("/force-change-password") @RequirePrincipal() @RequireValidAccessTokenForURL() public void changePasswordError(@Param("redirect") String redirect) throws IOException { var("redirect", redirect); var("forced", true); var("error", "validation"); encode("login/force_change_password"); } @Get("/logout") @RequireValidPrincipal() @WithDataAdapter(BergamotDB.class) public void logout(BergamotDB db) throws IOException { // deauth the current session deauthenticate(); // clean up any auto auth String autoAuthToken = sessionVar("bergamot.auto.login"); if (! Util.isEmpty(autoAuthToken)) { db.removeAPIToken(autoAuthToken); // nullify any auto auth cookie cookie() .name("bergamot.auto.login") .value("") .path(path("/login")) .expiresAfter(90, TimeUnit.DAYS) .httpOnly() .secure(request().isSecure()) .set(); } // redirect redirect("/login"); } /** * Perform a password reset */ @Get("/reset") public void reset(@Param("token") String token) throws IOException { // authenticate the token Contact contact = authenticateSingleFactor(new GenericAuthenticationToken(token, CryptoCookie.Flags.Reset), true); // assert that the contact requires a reset if (! contact.isForcePasswordChange()) { // if the password has already been reset, then // this request is a little odd, so force a login redirect(path("/login")); } // setup the session logger.info("Successfully authenticated password reset for user: " + contact.getName() + " => " + contact.getSiteId() + "::" + contact.getId()); // setup the session sessionVar("contact", currentPrincipal()); sessionVar("site", contact.getSite()); // force password change var("forced", true); encode("login/force_change_password"); } @Catch(BalsaSecurityException.class) @Order() @Post("/login") public void loginError(@Param("username") String username, @Param("redirect") String redirect) { // error during login var("error", "invalid"); var("redirect", redirect); var("username", cookie("bergamot.username")); // account this invalid login this.accounting.account(new LoginAccountingEvent(null, null, request().getServerName(), username, balsa().session().id(), false, false, request().getRemoteAddress())); // encode login page encode("login/login"); } @Catch(BalsaSecurityException.class) @Order(Order.LAST - 10) @Any("/**") public void forceLogin(@Param("redirect") String redirect) throws IOException { String to = Util.isEmpty(redirect) ? request().getPathInfo() : redirect; redirect("/login?redirect=" + Util.urlEncode(to, Util.UTF8)); } @Get("/reset-password") public void resetPassword(@Param("username") String username) throws IOException { var("username", Util.coalesceEmpty(username, cookie("bergamot.username"), null)); encode("login/reset_password"); } @Post("/reset-password") @RequireValidAccessTokenForURL() @WithDataAdapter(BergamotDB.class) public void doResetPassword(BergamotDB db, @Param("username") String username) throws IOException { // lookup the site Site site = db.getSiteByName(request().getServerName()); if (site != null) { // lookup the contact Contact contact = db.getContactByNameOrEmail(site.getId(), username); if (contact != null) { action("reset-password", contact); } else { var("error", "no-such-contact"); logger.info("Got password reset for a contact I don't know: '" + username + "'"); } } else { var("error", "no-such-site"); logger.info("Got password reset for a site I don't know: '" + request().getServerName() + "'"); } encode("login/reset_password_sent"); } }