OBP-API/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala
2026-01-29 22:38:48 +01:00

1747 lines
82 KiB
Scala
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
Open Bank Project - API
Copyright (C) 2011-2019, TESOBE GmbH.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Email: contact@tesobe.com
TESOBE GmbH.
Osloer Strasse 16/17
Berlin 13359, Germany
This product includes software developed at
TESOBE (http://www.tesobe.com/)
*/
package code.model.dataAccess
import code.UserRefreshes.UserRefreshes
import code.accountholders.AccountHolders
import code.api._
import code.api.cache.Caching
import code.api.dynamic.endpoint.helper.DynamicEndpointHelper
import code.api.util.APIUtil._
import code.api.util.CommonFunctions.validUri
import code.api.util.CommonsEmailWrapper._
import code.api.util.ErrorMessages._
import code.api.util._
import code.bankconnectors.Connector
import code.context.UserAuthContextProvider
import code.entitlement.Entitlement
import code.loginattempts.LoginAttempt
import code.snippet.WebUI
import code.token.TokensOpenIDConnect
import code.users.{UserAgreementProvider, Users}
import code.util.Helper
import code.util.Helper.{MdcLoggable, ObpS}
import code.util.HydraUtil._
import code.views.Views
import code.webuiprops.MappedWebUiPropsProvider.getWebUiPropsValue
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model._
import com.tesobe.CacheKeyFromArguments
import net.liftweb.common._
import net.liftweb.http.S.fmapFunc
import net.liftweb.http._
import net.liftweb.mapper._
import net.liftweb.sitemap.Loc.{If, LocParam, Template}
import net.liftweb.util._
import org.apache.commons.lang3.StringUtils
import sh.ory.hydra.api.AdminApi
import sh.ory.hydra.model.AcceptLoginRequest
import java.util.UUID.randomUUID
import scala.concurrent.Future
import scala.xml.{Elem, NodeSeq, Text}
/**
* An O-R mapped "User" class that includes first name, last name, password
*
* 1 AuthUser : is used for authentication, only for webpage Login in stuff
* 1) It is MegaProtoUser, has lots of methods for validation username, password, email ....
* Such as lost password, reset password .....
* Lift have some helper methods to make these things easily.
*
*
*
* 2 ResourceUser: is only a normal LongKeyedMapper
* 1) All the accounts, transactions ,roles, views, accountHolders, customers... should be linked to ResourceUser.userId_ field.
* 2) The consumer keys, tokens are also belong ResourceUser
*
*
* 3 RelationShips:
* 1)When `Sign up` new user --> create AuthUser --> call AuthUser.save --> create ResourceUser user.
* They share the same username and email.
* 2)AuthUser `user` field as the Foreign Key to link to Resource User.
* one AuthUser <---> one ResourceUser
*
*/
class AuthUser extends MegaProtoUser[AuthUser] with CreatedUpdated with MdcLoggable {
def getSingleton = AuthUser // what's the "meta" server
object user extends MappedLongForeignKey(this, ResourceUser)
object passwordShouldBeChanged extends MappedBoolean(this)
override lazy val firstName = new MyFirstName
protected class MyFirstName extends MappedString(this, 100) {
def isEmpty(msg: => String)(value: String): List[FieldError] =
value match {
case null => List(FieldError(this, Text(msg))) // issue 179
case e if e.trim.isEmpty => List(FieldError(this, Text(msg))) // issue 179
case _ => Nil
}
override def displayName = fieldOwner.firstNameDisplayName
override val fieldId = Some(Text("txtFirstName"))
override def validations = isEmpty(Helper.i18n("Please.enter.your.first.name")) _ :: super.validations
override def _toForm: Box[Elem] =
fmapFunc({s: List[String] => this.setFromAny(s)}){name =>
Full(appendFieldId(<input type={formInputType}
maxlength={maxLen.toString}
aria-labelledby={displayName}
aria-describedby={uniqueFieldId.getOrElse("")}
name={name}
value={get match {case null => "" case s => s.toString}}/>))
}
}
override lazy val lastName = new MyLastName
protected class MyLastName extends MappedString(this, 100) {
def isEmpty(msg: => String)(value: String): List[FieldError] =
value match {
case null => List(FieldError(this, Text(msg))) // issue 179
case e if e.trim.isEmpty => List(FieldError(this, Text(msg))) // issue 179
case _ => Nil
}
override def displayName = fieldOwner.lastNameDisplayName
override val fieldId = Some(Text("txtLastName"))
override def validations = isEmpty(Helper.i18n("Please.enter.your.last.name")) _ :: super.validations
override def _toForm: Box[Elem] =
fmapFunc({s: List[String] => this.setFromAny(s)}){name =>
Full(appendFieldId(<input type={formInputType}
maxlength={maxLen.toString}
aria-labelledby={displayName}
aria-describedby={uniqueFieldId.getOrElse("")}
name={name}
value={get match {case null => "" case s => s.toString}}/>))
}
}
/**
* Username is a valid email address or the regex below:
* Regex to validate a username
*
* ^(?=.{8,100}$)(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(?<![_.])$
* └─────┬────┘└───┬──┘└─────┬─────┘└─────┬─────┘ └───┬───┘
* │ │ │ │ no _ or . at the end
* │ │ │ │
* │ │ │ allowed characters
* │ │ │
* │ │ no __ or _. or ._ or .. inside
* │ │
* │ no _ or . at the beginning
*
* username is 8-100 characters long
*
*/
private val usernameRegex = """^(?=.{8,100}$)(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(?<![_.])$""".r
/**
* The username field for the User.
*/
lazy val username: userName = new userName()
class userName extends MappedString(this, 100) {
def isEmpty(msg: => String)(value: String): List[FieldError] =
value match {
case null => List(FieldError(this, Text(msg))) // issue 179
case e if e.trim.isEmpty => List(FieldError(this, Text(msg))) // issue 179
case _ => Nil
}
def usernameIsValid(msg: => String)(e: String) = e match {
case null => List(FieldError(this, Text(msg)))
case e if e.trim.isEmpty => List(FieldError(this, Text(msg)))
case e if emailRegex.findFirstMatchIn(e).isDefined => Nil // Email is valid username
case e if usernameRegex.findFirstMatchIn(e).isDefined => Nil
case _ => List(FieldError(this, Text(msg)))
}
override def displayName = Helper.i18n("Username")
@deprecated("Use UniqueIndex(username, provider)","27 December 2021")
override def dbIndexed_? = false // We use more general index UniqueIndex(username, provider) :: super.dbIndexes
override def validations = isEmpty(Helper.i18n("Please.enter.your.username")) _ ::
usernameIsValid(Helper.i18n("invalid.username")) _ ::
valUnique(Helper.i18n("unique.username")) _ ::
valUniqueExternally(Helper.i18n("unique.username")) _ ::
super.validations
override val fieldId = Some(Text("txtUsername"))
override def _toForm: Box[Elem] =
fmapFunc({s: List[String] => this.setFromAny(s)}){name =>
Full(appendFieldId(<input type={formInputType}
maxlength={maxLen.toString}
aria-labelledby={displayName}
aria-describedby={uniqueFieldId.getOrElse("")}
name={name}
value={get match {case null => "" case s => s.toString}}/>))
}
/**
* Make sure that the field is unique in the CBS
*/
def valUniqueExternally(msg: => String)(uniqueUsername: String): List[FieldError] ={
if (APIUtil.getPropsAsBoolValue("connector.user.authentication", false)) {
logger.info(s"valUniqueExternally: calling checkExternalUserExists for username: $uniqueUsername")
val connectorResult = Connector.connector.vend.checkExternalUserExists(uniqueUsername, None)
logger.info(s"valUniqueExternally: checkExternalUserExists returned: ${connectorResult.getClass.getSimpleName}")
connectorResult.map(_.sub) match {
case Full(returnedUsername) => // Get the username via connector
logger.info(s"valUniqueExternally: checkExternalUserExists returned username: $returnedUsername")
if(uniqueUsername == returnedUsername) { // Username is NOT unique
logger.info(s"valUniqueExternally: username $uniqueUsername already exists externally")
List(FieldError(this, Text(msg))) // provide the error message
} else {
logger.info(s"valUniqueExternally: username $uniqueUsername is unique (returned different: $returnedUsername)")
Nil // All good. Allow username creation
}
case ParamFailure(message,_,_,APIFailure(errorMessage, errorCode)) if errorMessage.contains("NO DATA") => // Cannot get the username via connector
logger.info(s"valUniqueExternally: checkExternalUserExists returned NO DATA for username: $uniqueUsername - allowing creation")
Nil // All good. Allow username creation
case Failure(failureMsg, exception, chain) =>
logger.warn(s"valUniqueExternally: checkExternalUserExists failed for username: $uniqueUsername, message: $failureMsg, exception: ${exception.map(_.getMessage)}, chain: $chain")
List(FieldError(this, Text(msg)))
case Empty =>
logger.warn(s"valUniqueExternally: checkExternalUserExists returned Empty for username: $uniqueUsername")
List(FieldError(this, Text(msg)))
case _ => // Any other case we provide error message
logger.warn(s"valUniqueExternally: checkExternalUserExists returned unexpected result for username: $uniqueUsername")
List(FieldError(this, Text(msg)))
}
} else {
Nil // All good. Allow username creation
}
}
}
override lazy val password = new MyPasswordNew
lazy val signupPasswordRepeatText = getWebUiPropsValue("webui_signup_body_password_repeat_text", S.?("repeat"))
class MyPasswordNew extends MappedPassword(this) {
lazy val preFilledPassword = if (APIUtil.getPropsAsBoolValue("allow_pre_filled_password", true)) {get.toString} else ""
override def _toForm: Box[NodeSeq] = {
S.fmapFunc({s: List[String] => this.setFromAny(s)}){funcName =>
Full(
<span>
{appendFieldId(<input id="textPassword" aria-labelledby="Password" aria-describedby={uniqueFieldId.getOrElse("")} type={formInputType} name={funcName} value={preFilledPassword}/> ) }
<div id="signup-error" class="alert alert-danger hide">
<span data-lift={s"Msg?id=${uniqueFieldId.getOrElse("")}&errorClass=error"}/>
</div>
<div id ="repeat-password">{signupPasswordRepeatText}</div>
<input id="textPasswordRepeat" aria-labelledby="Password Repeat" aria-describedby={uniqueFieldId.getOrElse("")} type={formInputType} name={funcName} value={preFilledPassword}/>
<div id="signup-error" class="alert alert-danger hide">
<span data-lift={s"Msg?id=${uniqueFieldId.getOrElse("")}_repeat&errorClass=error"}/>
</div>
</span>)
}
}
override def displayName = fieldOwner.passwordDisplayName
private var passwordValue = ""
private var invalidPw = false
private var invalidMsg = ""
// TODO Remove double negative and abreviation.
// TODO “invalidPw” = false -> “strongPassword = true” etc.
override def setFromAny(f: Any): String = {
def checkPassword() = {
def isPasswordEmpty() = {
if (passwordValue.isEmpty())
true
else {
passwordValue match {
case "*" | null | MappedPassword.blankPw =>
true
case _ =>
false
}
}
}
isPasswordEmpty() match {
case true =>
invalidPw = true;
invalidMsg = Helper.i18n("please.enter.your.password")
S.error("authuser_password_repeat", Text(Helper.i18n("please.re-enter.your.password")))
case false =>
if (fullPasswordValidation(passwordValue))
invalidPw = false
else {
invalidPw = true
invalidMsg = S.?(ErrorMessages.InvalidStrongPasswordFormat.split(':')(1))
S.error("authuser_password_repeat", Text(invalidMsg))
}
}
}
f match {
case a: Array[String] if (a.length == 2 && a(0) == a(1)) => {
passwordValue = a(0).toString
checkPassword()
this.set(a(0))
}
case l: List[_] if (l.length == 2 && l.head.asInstanceOf[String] == l(1).asInstanceOf[String]) => {
passwordValue = l(0).asInstanceOf[String]
checkPassword()
this.set(l.head.asInstanceOf[String])
}
case _ => {
invalidPw = true;
invalidMsg = Helper.i18n("passwords.do.not.match")
S.error("authuser_password_repeat", Text(invalidMsg))
}
}
get
}
override def validate: List[FieldError] = {
if (!invalidPw && password.get != "*") super.validate
else if (invalidPw) List(FieldError(this, Text(invalidMsg))) ++ super.validate
else List(FieldError(this, Text(Helper.i18n("please.enter.your.password")))) ++ super.validate
}
}
/**
* The provider field for the User.
*/
lazy val provider: userProvider = new userProvider()
class userProvider extends MappedString(this, 100) {
override def displayName = S.?("provider")
override val fieldId = Some(Text("txtProvider"))
override def validations = validUri(this) _ :: super.validations
override def defaultValue: String = Constant.localIdentityProvider
}
def getProvider() = {
if(provider.get == null || provider.get == "") {
Constant.localIdentityProvider
} else {
provider.get
}
}
def createUnsavedResourceUser() : ResourceUser = {
val user = Users.users.vend.createUnsavedResourceUser(getProvider(), Some(username.get), Some(username.get), Some(email.get), None).openOrThrowException(attemptedToOpenAnEmptyBox)
user
}
def getResourceUsersByEmail(userEmail: String) : List[ResourceUser] = {
Users.users.vend.getUserByEmail(userEmail) match {
case Full(userList) => userList
case _ => List()
}
}
def getResourceUserByProviderAndUsername(provider: String, username: String) : Box[User] = {
Users.users.vend.getUserByProviderAndUsername(provider, username)
}
override def save(): Boolean = {
if(! (user.defined_?)){
logger.info("user reference is null. We will create a ResourceUser")
val resourceUser = createUnsavedResourceUser()
val savedUser = Users.users.vend.saveResourceUser(resourceUser)
user(savedUser) //is this saving resourceUser into a user field?
}
else {
logger.info("user reference is not null. Trying to update the ResourceUser")
Users.users.vend.getResourceUserByResourceUserId(user.get).map{ u =>{
logger.info("API User found ")
u.name_(username.get)
.email(email.get)
.providerId(username.get)
.save
}
}
}
super.save
}
override def delete_!(): Boolean = {
user.obj.map(u => Users.users.vend.deleteResourceUser(u.id.get))
super.delete_!
}
// Regex to validate an email address as per W3C recommendations: https://www.w3.org/TR/html5/forms.html#valid-e-mail-address
private val emailRegex = """^[a-zA-Z0-9\.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$""".r
def isEmailValid(e: String): Boolean = e match{
case null => false
case e if e.trim.isEmpty => false
case e if emailRegex.findFirstMatchIn(e).isDefined => true
case _ => false
}
// Override the validate method of MappedEmail class
// There's no way to override the default emailPattern from MappedEmail object
override lazy val email = new MyEmail(this, 48) {
override def validations = super.validations
override def dbIndexed_? = false
override def validate = i_is_! match {
case null => List(FieldError(this, Text(Helper.i18n("Please.enter.your.email"))))
case e if e.trim.isEmpty => List(FieldError(this, Text(Helper.i18n("Please.enter.your.email"))))
case e if (!isEmailValid(e)) => List(FieldError(this, Text(Helper.i18n("invalid.email.address"))))
case _ => Nil
}
override def _toForm: Box[Elem] =
fmapFunc({s: List[String] => this.setFromAny(s)}){name =>
Full(appendFieldId(<input type={formInputType}
maxlength={maxLen.toString}
aria-labelledby={displayName}
aria-describedby={uniqueFieldId.getOrElse("")}
name={name}
value={get match {case null => "" case s => s.toString}}/>))
}
}
}
/**
* The singleton that has methods for accessing the database
*/
object AuthUser extends AuthUser with MetaMegaProtoUser[AuthUser]{
import net.liftweb.util.Helpers._
/**Marking the locked state to show different error message */
val usernameLockedStateCode = Long.MaxValue
val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ")
val starConnectorSupportedTypes = APIUtil.getPropsValue("starConnector_supported_types","")
override def dbIndexes: List[BaseIndex[AuthUser]] = UniqueIndex(username, provider) ::super.dbIndexes
override def emailFrom = Constant.mailUsersUserinfoSenderAddress
override def screenWrap = Full(<lift:surround with="default" at="content"><lift:bind /></lift:surround>)
// define the order fields will appear in forms and output
override def fieldOrder = List(id, firstName, lastName, email, username, password, provider)
override def signupFields = List(firstName, lastName, email, username, password)
// To force validation of email addresses set this to false (default as of 29 June 2021)
override def skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", false)
override def loginXhtml = {
val loginXml = Templates(List("templates-hidden","_login")).map({
"form [action]" #> {ObpS.uri} &
"#loginText * " #> {S.?("log.in")} &
"#usernameText * " #> {S.?("username")} &
"#passwordText * " #> {S.?("password")} &
"#login_challenge [value]" #> ObpS.param("login_challenge").getOrElse("") &
"autocomplete=off [autocomplete] " #> APIUtil.getAutocompleteValue &
"#recoverPasswordLink * " #> {
"a [href]" #> {lostPasswordPath.mkString("/", "/", "")} &
"a *" #> {S.?("recover.password")}
} &
"#SignUpLink * " #> {
"a [href]" #> {AuthUser.signUpPath.foldLeft("")(_ + "/" + _)} &
"a *" #> {S.?("sign.up")}
}
})
<div>{loginXml getOrElse NodeSeq.Empty}</div>
}
// Update ResourceUser.LastUsedLocale only once per session in 60 seconds
def updateComputedLocale(sessionId: String, computedLocale: String): Boolean = {
/**
* Please note that "var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)"
* is just a temporary value field with UUID values in order to prevent any ambiguity.
* The real value will be assigned by Macro during compile time at this line of a code:
* https://github.com/OpenBankProject/scala-macros/blob/master/macros/src/main/scala/com/tesobe/CacheKeyFromArgumentsMacro.scala#L49
*/
import scala.concurrent.duration._
val ttl: Duration = FiniteDuration(60, "second")
var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)
CacheKeyFromArguments.buildCacheKey {
Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(ttl) {
logger.debug(s"AuthUser.updateComputedLocale(sessionId = $sessionId, computedLocale = $computedLocale)")
getCurrentUser.map(_.userPrimaryKey.value) match {
case Full(id) =>
Users.users.vend.getResourceUserByResourceUserId(id).map {
u =>
u.LastUsedLocale(computedLocale).save
logger.debug(s"ResourceUser.LastUsedLocale is saved for the resource user id: $id")
}.isDefined
case _ => true// There is no current user
}
}
}
}
/**
* Find current ResourceUser from the server.
* This method has no parameters, it depends on different login types:
* AuthUser: AuthUser.currentUser
* OAuthHandshake: OAuthHandshake.getUser
* DirectLogin: DirectLogin.getUser
* to get the current Resourceuser .
*
*/
def getCurrentUser: Box[User] = {
val authorization: Box[String] = S.request.map(_.header("Authorization")).flatten
val directLogin: Box[String] = S.request.map(_.header("DirectLogin")).flatten
for {
resourceUser <- if (AuthUser.currentUser.isDefined){
//AuthUser.currentUser.get.user.foreign // this will be issue when the resource user is in remote side {
val user = AuthUser.currentUser.openOrThrowException(ErrorMessages.attemptedToOpenAnEmptyBox)
// In case that the provider is empty field we default to "local_identity_provider" or "hostname"
val provider =
if(user.provider.get == null || user.provider.get.isEmpty)
Constant.localIdentityProvider
else
user.provider.get
Users.users.vend.getUserByProviderAndUsername(provider, user.username.get)
} else if (directLogin.isDefined) // Direct Login
DirectLogin.getUser
else if (hasDirectLoginHeader(authorization)) // Direct Login Deprecated
DirectLogin.getUser
else if (hasAnOAuthHeader(authorization)) {
OAuthHandshake.getUser
} else if (hasGatewayHeader(authorization)){
GatewayLogin.getUser
} else {
logger.debug(ErrorMessages.CurrentUserNotFoundException)
Failure(ErrorMessages.CurrentUserNotFoundException)
}
} yield {
resourceUser
}
}
/**
* get current user.
* Note: 1. it will call getCurrentUser method,
*
*/
def getCurrentUserUsername: String = {
getCurrentUser match {
case Full(user) if user.provider.contains("google") && !user.emailAddress.isEmpty => user.emailAddress
case Full(user) if user.provider.contains("yahoo") && !user.emailAddress.isEmpty => user.emailAddress
case Full(user) if user.provider.contains("microsoft") && !user.emailAddress.isEmpty => user.emailAddress
case Full(user) => user.name
case _ => "" //TODO need more error handling for different user cases
}
}
def getIDTokenOfCurrentUser(): String = {
if(APIUtil.getPropsAsBoolValue("openid_connect.show_tokens", false)) {
AuthUser.currentUser match {
case Full(authUser) =>
TokensOpenIDConnect.tokens.vend.getOpenIDConnectTokenByAuthUser(authUser.id.get).map(_.idToken).getOrElse("")
case _ => ""
}
} else {
"This information is not allowed at this instance."
}
}
def getAccessTokenOfCurrentUser(): String = {
if(APIUtil.getPropsAsBoolValue("openid_connect.show_tokens", false)) {
AuthUser.currentUser match {
case Full(authUser) =>
TokensOpenIDConnect.tokens.vend.getOpenIDConnectTokenByAuthUser(authUser.id.get).map(_.accessToken).getOrElse("")
case _ => ""
}
} else {
"This information is not allowed at this instance."
}
}
/**
* get current user.userId
* Note: 1.resourceuser has two ids: id(Long) and userid_(String),
*
* @return return userid_(String).
*/
def getCurrentResourceUserUserId: String = {
getCurrentUser match{
case Full(user) => user.userId
case _ => "" //TODO need more error handling for different user cases
}
}
/**
* The string that's generated when the user name is not found. By
* default: S.?("email.address.not.found")
* The function is overridden in order to prevent leak of information at password reset page if username / email exists or do not exist.
* I.e. we want to prevent case in which an anonymous user can get information from the message does some username/email exist or no in our system.
*/
override def userNameNotFoundString: String = "Thank you. If we found a matching user, password reset instructions have been sent."
/**
* Overridden to use the hostname set in the props file
*/
override def sendPasswordReset(name: String) {
findAuthUserByUsernameLocallyLegacy(name).toList ::: findUsersByEmailLocally(name) map {
case u if u.validated_? =>
u.resetUniqueId().save
val resetPasswordLinkProps = Constant.HostName
val resetPasswordLink = APIUtil.getPropsValue("portal_hostname", resetPasswordLinkProps)+
passwordResetPath.mkString("/", "/", "/")+urlEncode(u.getUniqueId())
// Directly generate content using JakartaMail/CommonsEmailWrapper
val textContent = Some(s"Please use the following link to reset your password: $resetPasswordLink")
val htmlContent = Some(s"<p>Please use the following link to reset your password:</p><p><a href='$resetPasswordLink'>$resetPasswordLink</a></p>")
val emailContent = EmailContent(
from = emailFrom,
to = List(u.getEmail),
bcc = bccEmail.toList,
subject = passwordResetEmailSubject + " - " + u.username,
textContent = textContent,
htmlContent = htmlContent
)
sendHtmlEmail(emailContent) match {
case Full(messageId) =>
logger.debug(s"Password reset email sent successfully with Message-ID: $messageId")
S.notice("Password reset email sent successfully. Please check your email.")
S.redirectTo(homePage)
case Empty =>
logger.error("Failed to send password reset email")
S.error("Failed to send password reset email. Please try again.")
S.redirectTo(homePage)
}
case u =>
sendValidationEmail(u)
}
}
override def lostPasswordXhtml = {
<div id="recover-password" tabindex="-1">
<h1>Recover Password</h1>
<div id="recover-password-explanation">Enter your email address or username and we'll email you a link to reset your password</div>
<form action={ObpS.uri} method="post">
<div class="form-group">
<label>Username or email address</label> <span id="recover-password-email"><input id="email" type="text" /></span>
</div>
<div id="recover-password-submit">
<input type="submit" />
</div>
</form>
</div>
}
override def lostPassword = {
val bind =
"#email" #> SHtml.text("", sendPasswordReset _) &
"type=submit" #> lostPasswordSubmitButton(S.?("submit"))
bind(lostPasswordXhtml)
}
//override def def passwordResetMailBody(user: TheUserType, resetLink: String): Elem = { }
/**
* Overridden to use the hostname set in the props file
*/
override def sendValidationEmail(user: TheUserType) {
val resetLink = Constant.HostName+"/"+validateUserPath.mkString("/")+"/"+urlEncode(user.getUniqueId())
val email: String = user.getEmail
val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $resetLink")
val htmlContent = Some(s"<p>Welcome! Please validate your account by clicking the following link:</p><p><a href='$resetLink'>$resetLink</a></p>")
val subjectContent = "Sign up confirmation"
val emailContent = EmailContent(
from = emailFrom,
to = List(user.getEmail),
bcc = bccEmail.toList,
subject = subjectContent,
textContent = textContent,
htmlContent = htmlContent
)
sendHtmlEmail(emailContent) match {
case Full(messageId) =>
logger.debug(s"Validation email sent successfully with Message-ID: $messageId")
S.notice("Validation email sent successfully. Please check your email.")
case Empty =>
logger.error("Failed to send validation email")
S.error("Failed to send validation email. Please try again.")
}
}
def grantDefaultEntitlementsToAuthUser(user: TheUserType) = {
tryo{getResourceUserByProviderAndUsername(user.getProvider(), user.username.get).head.userId} match {
case Full(userId)=>APIUtil.grantDefaultEntitlementsToNewUser(userId)
case _ => logger.error("Can not getResourceUserByUsername here, so it breaks the grantDefaultEntitlementsToNewUser process.")
}
}
override def validateUser(id: String): NodeSeq = findUserByUniqueId(id) match {
case Full(user) if !user.validated_? =>
user.setValidated(true).resetUniqueId().save
grantDefaultEntitlementsToAuthUser(user)
logUserIn(user, () => {
S.notice(S.?("account.validated"))
APIUtil.getPropsValue("user_account_validated_redirect_url") match {
case Full(redirectUrl) =>
logger.debug(s"user_account_validated_redirect_url = $redirectUrl")
S.redirectTo(redirectUrl)
case _ =>
logger.debug(s"user_account_validated_redirect_url is NOT defined")
S.redirectTo(homePage)
}
})
case _ => S.error(S.?("invalid.validation.link")); S.redirectTo(homePage)
}
override def actionsAfterSignup(theUser: TheUserType, func: () => Nothing): Nothing = {
theUser.setValidated(skipEmailValidation).resetUniqueId()
theUser.save
val privacyPolicyValue: String = getWebUiPropsValue("webui_privacy_policy", "")
val termsAndConditionsValue: String = getWebUiPropsValue("webui_terms_and_conditions", "")
// User Agreement table
UserAgreementProvider.userAgreementProvider.vend.createUserAgreement(
theUser.user.foreign.map(_.userId).getOrElse(""), "privacy_conditions", privacyPolicyValue)
UserAgreementProvider.userAgreementProvider.vend.createUserAgreement(
theUser.user.foreign.map(_.userId).getOrElse(""), "terms_and_conditions", termsAndConditionsValue)
if (!skipEmailValidation) {
sendValidationEmail(theUser)
S.notice(S.?("sign.up.message"))
func()
} else {
grantDefaultEntitlementsToAuthUser(theUser)
logUserIn(theUser, () => {
S.notice(S.?("welcome"))
func()
})
}
}
/**
* Set this to redirect to a certain page after a failed login
*/
object failedLoginRedirect extends SessionVar[Box[String]](Empty) {
override lazy val __nameSalt = Helpers.nextFuncName
}
def agreeTermsDiv = {
val webUi = new WebUI
val webUiPropsValue = getWebUiPropsValue("webui_terms_and_conditions", "")
val termsAndConditionsCheckboxTitle = Helper.i18n("terms_and_conditions_checkbox_text", Some("I agree to the above Terms and Conditions"))
val termsAndConditionsCheckboxLabel = Helper.i18n("terms_and_conditions_checkbox_label", Some("Terms and Conditions"))
val agreeTermsHtml = s"""<hr>
| <div class="form-group" id="terms-and-conditions-div" onclick="enableDisableButton()">
| <details open style="cursor:s-resize;">
| <summary style="display:list-item;"><a class="api_group_name">$termsAndConditionsCheckboxLabel</a></summary>
| <div id="terms-and-conditions-page">${webUi.makeHtml(webUiPropsValue)}</div>
| </details>
| <input type="checkbox" class="form-check-input" id="terms_checkbox" >
| <label id="terms_checkbox_value" class="form-check-label" for="terms_checkbox">$termsAndConditionsCheckboxTitle</label>
| </div>
| """.stripMargin
scala.xml.Unparsed(agreeTermsHtml)
}
def legalNoticeDiv = {
val agreeTermsHtml = getWebUiPropsValue("webui_legal_notice_html_text", "")
if(agreeTermsHtml.isEmpty){
s""
} else{
scala.xml.Unparsed(s"""$agreeTermsHtml""")
}
}
def agreePrivacyPolicy = {
val webUi = new WebUI
val privacyPolicyCheckboxText = Helper.i18n("privacy_policy_checkbox_text", Some("I agree to the above Privacy Policy"))
val privacyPolicyCheckboxLabel = Helper.i18n("privacy_policy_checkbox_label", Some("Privacy Policy"))
val webUiPropsValue = getWebUiPropsValue("webui_privacy_policy", "")
val agreePrivacyPolicy = s"""<hr>
| <div class="form-group" id="privacy-conditions-div" onclick="enableDisableButton()">
| <details open style="cursor:s-resize;">
| <summary style="display:list-item;"><a class="api_group_name">$privacyPolicyCheckboxLabel</a></summary>
| <div id="privacy-policy-page">${webUi.makeHtml(webUiPropsValue)}</div>
| </details>
| <input id="privacy_checkbox" type="checkbox" class="form-check-input">
| <label class="form-check-label" for="privacy_checkbox">$privacyPolicyCheckboxText</label>
| </div>
| <hr>""".stripMargin
scala.xml.Unparsed(agreePrivacyPolicy)
}
def enableDisableSignUpButton = {
val javaScriptCode = """<script>
| function enableDisableButton() {
| var checkBox = document.getElementById("terms-and-conditions-div").querySelector("input[type=checkbox]");
| var checkBox2 = document.getElementById("privacy-conditions-div").querySelector("input[type=checkbox]");
| var button = document.getElementById("submit-button");
| if (checkBox.checked == true && checkBox2.checked == true){
| button.disabled = false;
| } else {
| button.disabled = true;
| }
| }
| </script>""".stripMargin
scala.xml.Unparsed(javaScriptCode)
}
def signupFormTitle = getWebUiPropsValue("webui_signup_form_title_text", S.?("sign.up"))
override def signupXhtml (user:AuthUser) = {
<div id="signup" tabindex="-1">
<form method="post" action={ObpS.uriAndQueryString.getOrElse(ObpS.uri)}>
<h1>{signupFormTitle}</h1>
{legalNoticeDiv}
<div id="signup-general-error" class="alert alert-danger hide"><span data-lift="Msg?id=error"/></div>
{localForm(user, false, signupFields)}
{agreeTermsDiv}
{agreePrivacyPolicy}
<div id="signup-submit">
<input onmouseover="enableDisableButton()" onfocus="enableDisableButton()" disabled="true" id="submit-button" type="submit" class="btn btn-danger"/>
</div>
{enableDisableSignUpButton}
</form>
</div>
}
override def localForm(user: TheUserType, ignorePassword: Boolean, fields: List[FieldPointerType]): NodeSeq = {
for {
pointer <- fields
field <- computeFieldFromPointer(user, pointer).toList
if field.show_? && (!ignorePassword || !pointer.isPasswordField_?)
form <- field.toForm.toList
} yield {
if(field.uniqueFieldId.getOrElse("") == "authuser_password") {
<div class="form-group">
<label>{field.displayName}</label>
{form}
</div>
} else {
<div class="form-group">
<label>{field.displayName}</label>
{form}
<div id="signup-error" class="alert alert-danger hide"><span data-lift={s"Msg?id=${field.uniqueFieldId.getOrElse("")}&errorClass=error"}/></div>
</div>
}
}
}
def userLoginFailed = {
logger.info("failed: " + failedLoginRedirect.get)
// variable redir is from failedLoginRedirect, it is set-up in OAuthAuthorisation.scala as following code:
// val currentUrl = ObpS.uriAndQueryString.getOrElse("/")
// AuthUser.failedLoginRedirect.set(Full(Helpers.appendParams(currentUrl, List((FailedLoginParam, "true")))))
val redir = failedLoginRedirect.get
//Check the internal redirect, in case for open redirect issue.
// variable redir is from loginRedirect, it is set-up in OAuthAuthorisation.scala as following code:
// val currentUrl = ObpS.uriAndQueryString.getOrElse("/")
// AuthUser.loginRedirect.set(Full(Helpers.appendParams(currentUrl, List((LogUserOutParam, "false")))))
if (Helper.isValidInternalRedirectUrl(redir.toString)) {
S.redirectTo(redir.toString)
} else {
S.error(S.?(ErrorMessages.InvalidInternalRedirectUrl))
logger.info(ErrorMessages.InvalidInternalRedirectUrl + loginRedirect.get)
}
S.error("login", S.?("Invalid Username or Password"))
}
def getResourceUserId(username: String, password: String): Box[Long] = {
findAuthUserByUsernameLocallyLegacy(username) match {
// We have a user from the local provider.
case Full(user) if (user.getProvider() == Constant.localIdentityProvider) =>
if (
user.validated_? &&
// User is NOT locked AND the password is good
! LoginAttempt.userIsLocked(user.getProvider(), username) &&
user.testPassword(Full(password)))
{
// We logged in correctly, so reset badLoginAttempts counter (if it exists)
LoginAttempt.resetBadLoginAttempts(user.getProvider(), username)
Full(user.user.get) // Return the user.
}
// User is unlocked AND password is bad
else if (
user.validated_? &&
! LoginAttempt.userIsLocked(user.getProvider(), username) &&
! user.testPassword(Full(password))
) {
LoginAttempt.incrementBadLoginAttempts(user.getProvider(), username)
Empty
}
// User is locked
else if (LoginAttempt.userIsLocked(user.getProvider(), username))
{
LoginAttempt.incrementBadLoginAttempts(user.getProvider(), username)
logger.info(ErrorMessages.UsernameHasBeenLocked)
//TODO need to fix, use Failure instead, it is used to show the error message to the GUI
Full(usernameLockedStateCode)
}
else {
// Nothing worked, so just increment bad login attempts
LoginAttempt.incrementBadLoginAttempts(user.getProvider(), username)
Empty
}
// We have a user from an external provider.
case Full(user) if (user.getProvider() != Constant.localIdentityProvider) =>
APIUtil.getPropsAsBoolValue("connector.user.authentication", false) match {
case true if !LoginAttempt.userIsLocked(user.getProvider(), username) =>
val userId =
for {
authUser <- checkExternalUserViaConnector(username, password)
resourceUser <- tryo {
authUser.user
}
} yield {
LoginAttempt.resetBadLoginAttempts(user.getProvider(), username)
resourceUser.get
}
userId match {
case Full(l: Long) => Full(l)
case _ =>
LoginAttempt.incrementBadLoginAttempts(user.getProvider(), username)
Empty
}
case false =>
LoginAttempt.incrementBadLoginAttempts(user.getProvider(), username)
Empty
}
// Everything else.
case _ =>
LoginAttempt.incrementBadLoginAttempts(user.foreign.map(_.provider).getOrElse(Constant.HostName), username)
Empty
}
}
/**
* This method is belong to AuthUser, it is used for authentication(Login stuff)
* 1 get the user over connector.
* 2 check whether it is existing in AuthUser table in obp side.
* 3 if not existing, will create new AuthUser.
* @return Return the authUser
*/
def checkExternalUserViaConnector(username: String, password: String):Box[AuthUser] = {
logger.info(s"checkExternalUserViaConnector: calling checkExternalUserCredentials for username: $username")
val connectorResult = Connector.connector.vend.checkExternalUserCredentials(username, password, None)
logger.info(s"checkExternalUserViaConnector: checkExternalUserCredentials returned: ${connectorResult.getClass.getSimpleName}")
connectorResult match {
case Full(InboundExternalUser(aud, exp, iat, iss, sub, azp, email, emailVerified, name, userAuthContexts)) =>
logger.info(s"checkExternalUserViaConnector: successful response for sub: $sub, iss: $iss, email: $email")
val user = findAuthUserByUsernameAndProvider(sub, iss) match { // Check if the external user is already created locally
case Full(user) if user.validated_? => // Return existing user if found
logger.debug("external user already exists locally, using that one")
userAuthContexts match {
case Some(authContexts) => // Write user auth context to the database
UserAuthContextProvider.userAuthContextProvider.vend.createOrUpdateUserAuthContexts(user.userIdAsString, authContexts)
case None => // Do nothing
}
user
case _ => // If not found, create a new user
// Create AuthUser using fetched data from connector
// assuming that user's email is always validated
logger.debug("external user "+ sub + " does not exist locally, creating one")
AuthUser.create
.firstName(name.getOrElse(sub))
.email(email.getOrElse(""))
.username(sub)
// No need to store password, so store dummy string instead
.password(generateUUID())
// TODO add field stating external password check only.
.provider(iss)
.validated(emailVerified.exists(_.equalsIgnoreCase("true")))
.saveMe() //NOTE, we will create the resourceUser in the `saveMe()` method.
}
userAuthContexts match {
case Some(authContexts) => { // Write user auth context to the database
// get resourceUserId from AuthUser.
val resourceUserId = user.user.foreign.map(_.userId).getOrElse("")
// we try to catch this exception, the createOrUpdateUserAuthContexts can not break the login process.
tryo {UserAuthContextProvider.userAuthContextProvider.vend.createOrUpdateUserAuthContexts(resourceUserId, authContexts)}
.openOr(logger.error(s"${resourceUserId} checkExternalUserViaConnector.createOrUpdateUserAuthContexts throw exception! "))
}
case None => // Do nothing
}
Full(user)
case Failure(msg, exception, chain) =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials failed for username: $username, message: $msg, exception: ${exception.map(_.getMessage)}, chain: $chain")
Empty
case Empty =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned Empty for username: $username")
Empty
case _ =>
logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned unexpected result for username: $username")
Empty
}
}
def restoreSomeSessions(): Unit = {
activeBrand()
}
override protected def capturePreLoginState(): () => Unit = () => {restoreSomeSessions}
/**
* The LocParams for the menu item for login.
* Overridden in order to add custom error message. Attention: Not calling super will change the default behavior!
*/
override protected def loginMenuLocParams: List[LocParam[Unit]] = {
If(notLoggedIn_? _, () => RedirectResponse("/already-logged-in")) ::
Template(() => wrapIt(login)) ::
Nil
}
//overridden to allow a redirection if login fails
/**
* Success cases:
* case1: user validated && user not locked && user.provider from localhost && password correct --> Login in
* case2: user validated && user not locked && user.provider not localhost && password correct --> Login in
* case3: user from remote && checked over connector --> Login in
*
* Error cases:
* case1: user is locked --> UsernameHasBeenLocked
* case2: user.validated_? --> account.validation.error
* case3: right username but wrong password --> Invalid Login Credentials
* case4: wrong username --> Invalid Login Credentials
* case5: UnKnow error --> UnexpectedErrorDuringLogin
*/
override def login: NodeSeq = {
// This query parameter is specific to ORY Hydra login request
val loginChallenge: Box[String] = ObpS.param("login_challenge").or(S.getSessionAttribute("login_challenge"))
def redirectUri(user: Box[ResourceUser]): String = {
val userId = user.map(_.userId).getOrElse("")
val hashedAgreementTextOfUser =
UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(userId, "terms_and_conditions")
.map(_.agreementHash).getOrElse(HashUtil.Sha256Hash("not set"))
val agreementText = getWebUiPropsValue("webui_terms_and_conditions", "not set")
val hashedAgreementText = HashUtil.Sha256Hash(agreementText)
if(hashedAgreementTextOfUser == hashedAgreementText) { // Check terms and conditions
val hashedAgreementTextOfUser =
UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(userId, "privacy_conditions")
.map(_.agreementHash).getOrElse(HashUtil.Sha256Hash("not set"))
val agreementText = getWebUiPropsValue("webui_privacy_policy", "not set")
val hashedAgreementText = HashUtil.Sha256Hash(agreementText)
if(hashedAgreementTextOfUser == hashedAgreementText) { // Check privacy policy
loginRedirect.get match {
case Full(url) =>
loginRedirect(Empty)
url
case _ =>
homePage
}
} else {
"/privacy-policy"
}
} else {
"/terms-and-conditions"
}
}
//Check the internal redirect, in case for open redirect issue.
// variable redirect is from loginRedirect, it is set-up in OAuthAuthorisation.scala as following code:
// val currentUrl = ObpS.uriAndQueryString.getOrElse("/")
// AuthUser.loginRedirect.set(Full(Helpers.appendParams(currentUrl, List((LogUserOutParam, "false")))))
def checkInternalRedirectAndLogUserIn(preLoginState: () => Unit, redirect: String, user: AuthUser) = {
if (Helper.isValidInternalRedirectUrl(redirect)) {
logUserIn(user, () => {
S.notice(S.?("logged.in"))
preLoginState()
if(emailDomainToSpaceMappings.nonEmpty){
Future{
tryo{AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(user)}
.openOr(logger.error(s"${user} checkInternalRedirectAndLogUserIn.grantEntitlementsToUseDynamicEndpointsInSpaces throw exception! "))
}}
if(emailDomainToEntitlementMappings.nonEmpty){
Future{
tryo{AuthUser.grantEmailDomainEntitlementsToUser(user)}
.openOr(logger.error(s"${user} checkInternalRedirectAndLogUserIn.grantEmailDomainEntitlementsToUser throw exception! "))
}}
// We use Hydra as an Headless Identity Provider which implies OBP-API must provide User Management.
// If there is the query parameter login_challenge in a url we know it is tha Hydra request
// TODO Write standalone application for Login and Consent Request of Hydra as Identity Provider
integrateWithHydra match {
case true =>
if (loginChallenge.isEmpty == false) {
val acceptLoginRequest = new AcceptLoginRequest
val adminApi: AdminApi = new AdminApi
acceptLoginRequest.setSubject(user.username.get)
val result = adminApi.acceptLoginRequest(loginChallenge.getOrElse(""), acceptLoginRequest)
S.redirectTo(result.getRedirectTo)
} else {
S.redirectTo(redirect)
}
case false =>
S.redirectTo(redirect)
}
})
} else {
S.error(S.?(ErrorMessages.InvalidInternalRedirectUrl))
logger.info(ErrorMessages.InvalidInternalRedirectUrl + loginRedirect.get)
}
}
def isObpProvider(user: AuthUser) = {
// TODO Consider does http://host should match https://host in development mode
user.getProvider() == Constant.localIdentityProvider
}
def obpUserIsValidatedAndNotLocked(usernameFromGui: String, user: AuthUser) = {
user.validated_? && !LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) &&
isObpProvider(user)
}
def externalUserIsValidatedAndNotLocked(usernameFromGui: String, user: AuthUser) = {
user.validated_? && !LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) &&
!isObpProvider(user)
}
def loginAction = {
if (S.post_?) {
val usernameFromGui = ObpS.param("username").getOrElse("")
val passwordFromGui = ObpS.param("password").getOrElse("")
val usernameEmptyField = ObpS.param("username").map(_.isEmpty()).getOrElse(true)
val passwordEmptyField = ObpS.param("password").map(_.isEmpty()).getOrElse(true)
val emptyField = usernameEmptyField || passwordEmptyField
emptyField match {
case true =>
if(usernameEmptyField)
S.error("login-form-username-error", Helper.i18n("please.enter.your.username"))
if(passwordEmptyField)
S.error("login-form-password-error", Helper.i18n("please.enter.your.password"))
case false =>
findAuthUserByUsernameLocallyLegacy(usernameFromGui) match {
case Full(user) if !user.validated_? =>
S.error(S.?("account.validation.error"))
// Check if user comes from localhost and
case Full(user) if obpUserIsValidatedAndNotLocked(usernameFromGui, user) =>
if(user.testPassword(Full(passwordFromGui))) { // if User is NOT locked and password is good
// Reset any bad attempt
LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui)
val preLoginState = capturePreLoginState()
// User init actions
AfterApiAuth.innerLoginUserInitAction(Full(user))
logger.info("login redirect: " + loginRedirect.get)
val redirect = redirectUri(user.user.foreign)
checkInternalRedirectAndLogUserIn(preLoginState, redirect, user)
} else { // If user is NOT locked AND password is wrong => increment bad login attempt counter.
LoginAttempt.incrementBadLoginAttempts(user.getProvider(),usernameFromGui)
S.error(Helper.i18n("invalid.login.credentials"))
}
// If user is locked, send the error to GUI
case Full(user) if LoginAttempt.userIsLocked(user.getProvider(), usernameFromGui) =>
LoginAttempt.incrementBadLoginAttempts(user.getProvider(),usernameFromGui)
S.error(S.?(ErrorMessages.UsernameHasBeenLocked))
loginRedirect(ObpS.param("Referer").or(S.param("Referer")))
// Check if user came from CBS and
// if User is NOT locked. Then check username and password
// from connector in case they changed on the south-side
case Full(user) if externalUserIsValidatedAndNotLocked(usernameFromGui, user) && testExternalPassword(usernameFromGui, passwordFromGui) =>
// Reset any bad attempts
LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui)
val preLoginState = capturePreLoginState()
logger.info("login redirect: " + loginRedirect.get)
val redirect = redirectUri(user.user.foreign)
//This method is used for connector = cbs* || obpjvm*
//It will update the views and createAccountHolder ....
registeredUserHelper(user.getProvider(),user.username.get)
// User init actions
AfterApiAuth.innerLoginUserInitAction(Full(user))
checkInternalRedirectAndLogUserIn(preLoginState, redirect, user)
// Error case:
// the username exist but provider cannot be matched
// It can happen via next scenario:
// - sign up user at some obp-api cluster
// - change a url of the cluster
// - try to log on user at the cluster
case Full(user) if !isObpProvider(user) =>
S.error(S.?(s"${ErrorMessages.InvalidProviderUrl} Actual: ${Constant.localIdentityProvider}, Expected: ${user.provider}"))
// If user cannot be found locally, try to authenticate user via connector
case Empty if (APIUtil.getPropsAsBoolValue("connector.user.authentication", false)) =>
val preLoginState = capturePreLoginState()
logger.info("login redirect: " + loginRedirect.get)
val redirect = redirectUri(user.foreign)
externalUserHelper(usernameFromGui, passwordFromGui) match {
case Full(user: AuthUser) =>
LoginAttempt.resetBadLoginAttempts(user.getProvider(), usernameFromGui)
// User init actions
AfterApiAuth.innerLoginUserInitAction(Full(user))
checkInternalRedirectAndLogUserIn(preLoginState, redirect, user)
case _ =>
LoginAttempt.incrementBadLoginAttempts(user.foreign.map(_.provider).getOrElse(Constant.HostName), username.get)
Empty
S.error(Helper.i18n("invalid.login.credentials"))
}
//If there is NO the username, throw the error message.
case Empty =>
S.error(Helper.i18n("invalid.login.credentials"))
case unhandledCase =>
logger.error("------------------------------------------------------")
logger.error(s"username from GUI: $usernameFromGui")
logger.error("An unexpected login error occurred:")
logger.error(unhandledCase)
logger.error("------------------------------------------------------")
LoginAttempt.incrementBadLoginAttempts(user.foreign.map(_.provider).getOrElse(Constant.HostName), usernameFromGui)
S.error(S.?(ErrorMessages.UnexpectedErrorDuringLogin)) // Note we hit this if user has not clicked email validation link
}
}
}
}
// In this function we bind submit button to loginAction function.
// In case that unique token of submit button cannot be paired submit action will be omitted.
// Implemented in order to prevent a CSRF attack
def insertSubmitButton = {
scala.xml.XML.loadString(loginSubmitButton(loginButtonText, loginAction _).toString().replace("type=\"submit\"","class=\"submit\" type=\"submit\""))
}
val bind =
"submit" #> insertSubmitButton
bind(loginXhtml)
}
override def logout = {
logoutCurrentUser
S.request match {
case Full(a) => a.param("redirect") match {
case Full(customRedirect) => S.redirectTo(customRedirect)
case _ => S.redirectTo(homePage)
}
case _ => S.redirectTo(homePage)
}
}
/**
* The user authentications is not exciting in obp side, it need get the user via connector
*/
def testExternalPassword(usernameFromGui: String, passwordFromGui: String): Boolean = {
checkExternalUserViaConnector(usernameFromGui, passwordFromGui) match {
case Full(user:AuthUser) => true
case _ => false
}
}
/**
* This method will update the views and createAccountHolder ....
*/
def externalUserHelper(name: String, password: String): Box[AuthUser] = {
for {
user <- checkExternalUserViaConnector(name, password)
u <- Users.users.vend.getUserByProviderAndUsername(user.getProvider(), name)
} yield {
user
}
}
/**
* This method will update the views and createAccountHolder ....
*/
def registeredUserHelper(provider: String, username: String) = {
if (connector.startsWith("rest_vMar2019")) {
for {
u <- Users.users.vend.getUserByProviderAndUsername(provider, username)
} yield {
refreshUserLegacy(u, None)
}
}
}
/**
* A Space is an alias for the OBP Bank. Each Bank / Space can contain many Dynamic Endpoints. If a User belongs to a Space,
* the User can use those endpoints but not modify them. If a User creates a Bank (aka Space) the user can create
* and modify Dynamic Endpoints and other objects in that Bank / Space.
*
* @return
*/
def mySpaces(user: AuthUser): List[BankId] = {
//1st: first check the user is validated
if (user.validated_?) {
//userEmail = robert.uk.29@example.com
// 2st get the email domain - `example.com`
val emailDomain = StringUtils.substringAfterLast(user.email.get, "@")
//3 return the bankIds
emailDomainToSpaceMappings.collectFirst {
case EmailDomainToSpaceMapping(`emailDomain`, ids) => ids.map(BankId(_));
} getOrElse Nil
} else {
Nil
}
}
def grantEntitlementsToUseDynamicEndpointsInSpaces(user: AuthUser) = {
if(emailDomainToSpaceMappings.nonEmpty) {
val createdByProcess = "grantEntitlementsToUseDynamicEndpointsInSpaces"
val userId = user.user.obj.map(_.userId).getOrElse("")
// user's already auto granted entitlements.
val entitlementsGrantedByThisProcess = Entitlement.entitlement.vend.getEntitlementsByUserId(userId)
.map(_.filter(role => role.createdByProcess == createdByProcess))
.getOrElse(Nil)
def alreadyHasEntitlement(role:ApiRole, bankId: String): Boolean =
entitlementsGrantedByThisProcess.exists(entitlement => entitlement.roleName == role.toString() && entitlement.bankId == bankId)
//call mySpaces --> get BankIds --> listOfRolesToUseAllDynamicEndpointsAOneBank (at each bank)--> Grant roles (for each role)
val allCurrentDynamicRoleToBankIdPairs: List[(ApiRole, String)] = for {
BankId(bankId) <- mySpaces(user: AuthUser)
role <- DynamicEndpointHelper.listOfRolesToUseAllDynamicEndpointsAOneBank(Some(bankId))
} yield {
if (!alreadyHasEntitlement(role, bankId)) {
Entitlement.entitlement.vend.addEntitlement(bankId, userId, role.toString, createdByProcess)
}
role -> bankId
}
// if user's auto granted entitlement invalid, delete it.
// invalid happens when some dynamic endpoints are removed, so the entitlements linked to the deleted dynamic endpoints are invalid.
for {
grantedEntitlement <- entitlementsGrantedByThisProcess
grantedEntitlementRoleName = grantedEntitlement.roleName
grantedEntitlementBankId = grantedEntitlement.bankId
} {
val isInValidEntitlement = !allCurrentDynamicRoleToBankIdPairs.exists { roleToBankIdPair =>
val(role, roleBankId) = roleToBankIdPair
role.toString() == grantedEntitlementRoleName && roleBankId == grantedEntitlementBankId
}
if(isInValidEntitlement) {
Entitlement.entitlement.vend.deleteEntitlement(Full(grantedEntitlement))
}
}
}
}
def grantEmailDomainEntitlementsToUser(user: AuthUser) = {
if(emailDomainToEntitlementMappings.nonEmpty){
val createdByProcess = "grantEmailDomainEntitlementsToUser"
val userId = user.user.obj.map(_.userId).getOrElse("")
// user's already auto granted entitlements.
val entitlementsGrantedByThisProcess = Entitlement.entitlement.vend.getEntitlementsByUserId(userId)
.map(_.filter(role => role.createdByProcess == createdByProcess))
.getOrElse(Nil)
def alreadyHasEntitlement(bankId: String, roleName:String): Boolean =
entitlementsGrantedByThisProcess.exists(entitlement => entitlement.roleName == roleName && entitlement.bankId == bankId)
val allEntitlementsFromCurrentProps: List[(String, String)] = for{
emailDomainToEntitlementMapping <- emailDomainToEntitlementMappings
domain = emailDomainToEntitlementMapping.domain
entitlement <- emailDomainToEntitlementMapping.entitlements if StringUtils.substringAfterLast(user.email.get, "@") == domain
roleName = entitlement.role_name
roleBankId = entitlement.bank_id
} yield {
if (!alreadyHasEntitlement(roleBankId, roleName)) {
Entitlement.entitlement.vend.addEntitlement(roleBankId, userId, roleName, createdByProcess)
}
roleName -> roleBankId
}
// if user's auto granted entitlement invalid, delete it.
// invalid happens when some dynamic endpoints are removed, so the entitlements linked to the deleted dynamic endpoints are invalid.
for {
grantedEntitlement <- entitlementsGrantedByThisProcess
grantedEntitlementRoleName = grantedEntitlement.roleName
grantedEntitlementBankId = grantedEntitlement.bankId
} {
val isInValidEntitlement = !allEntitlementsFromCurrentProps.exists { roleNameToBankIdPair =>
val(roleName, roleBankId) = roleNameToBankIdPair
roleName == grantedEntitlementRoleName && roleBankId == grantedEntitlementBankId
}
if(isInValidEntitlement) {
Entitlement.entitlement.vend.deleteEntitlement(Full(grantedEntitlement))
}
}
}
}
/**
* This method is used for onboarding bank customer to OBP.
* 1st: we will get all the accountsHeld from CBS side.
* 2rd: we will create the account Holder, view and account accesses.
*/
def refreshUser(user: User, callContext: Option[CallContext]) = {
for{
(accountsHeld, _) <- Connector.connector.vend.getBankAccountsForUser(user.provider, user.name,callContext) map {
connectorEmptyResponse(_, callContext)
}
_ = logger.debug(s"--> for user($user): AuthUser.refreshUser.accountsHeld : ${accountsHeld}")
success = refreshViewsAccountAccessAndHolders(user, accountsHeld, callContext)
}yield {
success
}
}
@deprecated("This return Box, not a future, try to use @refreshUser instead. ","08-09-2023")
def refreshUserLegacy(user: User, callContext: Option[CallContext]) = {
for{
(accountsHeld, _) <- Connector.connector.vend.getBankAccountsForUserLegacy(user.provider, user.name, callContext)
_ = logger.debug(s"--> for user($user): AuthUser.refreshUserLegacy.accountsHeld : ${accountsHeld}")
success = refreshViewsAccountAccessAndHolders(user, accountsHeld, callContext)
}yield {
success
}
}
/**
* This is a helper method
* create/update/delete the views, accountAccess, accountHolders for OBP get accounts from CBS side.
* This method can only be used by the original user(account holder).
* InboundAccount return many fields, but in this method, we only need bankId, accountId and viewId so far.
*/
def refreshViewsAccountAccessAndHolders(user: User, accountsHeld: List[InboundAccount], callContext: Option[CallContext]) = {
if(user.isOriginalUser){
//first, we compare the accounts in obp and the accounts in cbs,
val (_, privateAccountAccess) = Views.views.vend.privateViewsUserCanAccess(user)
val obpAccountAccessBankAccountIds = privateAccountAccess.map(accountAccess =>BankIdAccountId(BankId(accountAccess.bank_id.get), AccountId(accountAccess.account_id.get))).toSet
// This will return all account held for the user, no mater what the source is.
val userOwnBankAccountIds = AccountHolders.accountHolders.vend.getAccountsHeldByUser(user)
//The accounts from AccountAccess may contains other users' account info, so here we filter the accounts By account holder, only show the user's own accounts
val obpBankAccountIds = obpAccountAccessBankAccountIds.filter(bankAccountId => userOwnBankAccountIds.contains(bankAccountId)).toSet
//The accounts from AccountAccess may contains other users' account info, so here we filter the accounts By account holder, only show the user's own accounts
val cbsBankAccountIds = accountsHeld.map(account =>BankIdAccountId(BankId(account.bankId),AccountId(account.accountId))).toSet
//cbs removed this accounts, but OBP still contains the data for them, so we need to clean data in OBP side.
val cbsRemovedBankAccountIds = obpBankAccountIds diff cbsBankAccountIds
//cbs has new accounts which are not in obp yet, we need to create new data for these accounts.
val csbNewBankAccountIds = cbsBankAccountIds diff obpBankAccountIds
logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedBankAccountIds-------"+cbsRemovedBankAccountIds)
logger.debug("refreshViewsAccountAccessAndHolders.csbNewBankAccountIds-------" + csbNewBankAccountIds)
//1rd remove the deprecated accounts
//TODO. need to double check if we need to clean accountidmapping table, account meta data (MappedTag) ....
for{
cbsRemovedBankAccountId <- cbsRemovedBankAccountIds
_ = logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedBankAccountIds.cbsRemovedBankAccountId: start-------" + cbsRemovedBankAccountId)
bankId = cbsRemovedBankAccountId.bankId
accountId = cbsRemovedBankAccountId.accountId
_ = Views.views.vend.revokeAccountAccessByUser(bankId, accountId, user, callContext)
_ = AccountHolders.accountHolders.vend.deleteAccountHolder(user,cbsRemovedBankAccountId)
cbsAccount = accountsHeld.find(cbsAccount =>cbsAccount.bankId == bankId.value && cbsAccount.accountId == accountId.value)
viewId <- cbsAccount.map(_.viewsToGenerate).getOrElse(List.empty[String])
_=UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId)
success <- Views.views.vend.removeCustomView(ViewId(viewId), cbsRemovedBankAccountId)
_ = logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedBankAccountIds.cbsRemovedBankAccountId: finish-------" + cbsRemovedBankAccountId)
} yield {
success
}
//2st: create views/accountAccess/accountHolders for the new coming accounts
for {
newBankAccountId <- csbNewBankAccountIds
_ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewBankAccountId.newBankAccountId: start-------" + newBankAccountId)
_ = AccountHolders.accountHolders.vend.getOrCreateAccountHolder(user,newBankAccountId,Some("UserAuthContext"))
bankId = newBankAccountId.bankId
accountId = newBankAccountId.accountId
newBankAccount = accountsHeld.find(cbsAccount =>cbsAccount.bankId == bankId.value && cbsAccount.accountId == accountId.value)
viewId <- newBankAccount.map(_.viewsToGenerate).getOrElse(List.empty[String])
view <- Views.views.vend.getOrCreateSystemViewFromCbs(viewId)//TODO, only support system views so far, may add custom views later.
_=UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId)
view <- if (view.isSystem) //if the view is a system view, we will call `grantAccessToSystemView`
Views.views.vend.grantAccessToSystemView(bankId, accountId, view, user)
else //otherwise, we will call `grantAccessToCustomView`
Views.views.vend.grantAccessToCustomView(view.uid, user)
_ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewBankAccountId.newBankAccountId: finish-------" + newBankAccountId)
} yield {
view
}
//3rd: if the ids are not change, but views are changed, we still need compare the view for each account:
if(cbsRemovedBankAccountIds.equals(csbNewBankAccountIds)) {
for {
bankAccountId <- obpBankAccountIds
// we can not get the views from the `viewDefinition` table, because we can not delete system views at all. we need to read the view from accountAccess table.
//obpViewsForAccount = MapperViews.availableViewsForAccount(bankAccountId).map(_.viewId.value)
obpViewsForAccount = Views.views.vend.privateViewsUserCanAccessForAccount(user, bankAccountId).map(_.viewId.value)
_ = logger.debug("refreshViewsAccountAccessAndHolders.obpViewsForAccount-------" + obpViewsForAccount)
cbsViewsForAccount = accountsHeld.find(account => account.bankId.equals(bankAccountId.bankId.value) && account.accountId.equals(bankAccountId.accountId.value)).map(_.viewsToGenerate).getOrElse(Nil)
_ = logger.debug("refreshViewsAccountAccessAndHolders.cbsViewsForAccount-------" + cbsViewsForAccount)
//cbs removed these views, but OBP still contains the data for them, so we need to clean data in OBP side.
cbsRemovedViewsForAccount = obpViewsForAccount diff cbsViewsForAccount
_ = logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedViewsForAccount-------" + cbsRemovedViewsForAccount)
_ = if(cbsRemovedViewsForAccount.nonEmpty){
val cbsRemovedBankIdAccountIdViewIds = cbsRemovedViewsForAccount.map(view => BankIdAccountIdViewId(bankAccountId.bankId, bankAccountId.accountId, ViewId(view)))
Views.views.vend.revokeAccessToMultipleViews(cbsRemovedBankIdAccountIdViewIds, user)
cbsRemovedViewsForAccount.map(view =>Views.views.vend.removeCustomView(ViewId(view), bankAccountId))
UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId)
}
//cbs has new views which are not in obp yet, we need to create new data for these accounts.
csbNewViewsForAccount = cbsViewsForAccount diff obpViewsForAccount
_ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount-------" + csbNewViewsForAccount)
success = if(csbNewViewsForAccount.nonEmpty){
for{
newViewForAccount <- csbNewViewsForAccount
_ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount start:-------" + newViewForAccount)
view <- Views.views.vend.getOrCreateSystemViewFromCbs(newViewForAccount)//TODO, only support system views so far, may add custom views later.
_ = UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId)
view <- if (view.isSystem) //if the view is a system view, we will call `grantAccessToSystemView`
Views.views.vend.grantAccessToSystemView(bankAccountId.bankId, bankAccountId.accountId, view, user)
else //otherwise, we will call `grantAccessToCustomView`
Views.views.vend.grantAccessToCustomView(view.uid, user)
_ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount finish:-------" + newViewForAccount)
}yield{
view
}
}
} yield {
success
}
}
true
}
else {
false
}
}
/*
┌────────────┐
│FIND A USER │
│AT MAPPER DB│
└──────┬─────┘
___________▽___________ ┌────────────────────────┐
props: ╲ │FIND USER BY COMPOSITE │
local_identity_provider ╲______________________│KEY (username, │
yes │local_identity_provider)│
╲_______________________ └────────────┬───────────┘
│no │
___▽____ ┌────────────────────────┐ │
props: ╲ │FIND USER BY COMPOSITE │ │
hostname ╲___│KEY (username, hostname)│ │
yes└────────────┬───────────┘ │
╲________ │ │
│no │ │
┌──▽──┐ │ │
│ERROR│ │ │
└─────┘ │ │
└──────┬──────────────────┘
┌────▽────┐
│BOX[USER]│
└─────────┘
*/
/**
* Find the Auth User by the composite key (username, provider).
* Only search at the local database.
* Please note that provider is implicitly defined i.e. not provided via a parameter
*/
@deprecated("AuthUser unique key is username and provider, please use @findAuthUserByUsernameAndProvider instead.","06.06.2024")
def findAuthUserByUsernameLocallyLegacy(name: String): Box[TheUserType] = {
// 1st try is provider with local_identity_provider or hostname value
find(By(this.username, name), By(this.provider, Constant.localIdentityProvider))
// 2nd try is provider with null value
.or(find(By(this.username, name), NullRef(this.provider)))
// 3rd try is provider with empty string value
.or(find(By(this.username, name), By(this.provider, "")))
}
def findAuthUserByUsernameAndProvider(name: String, provider: String): Box[TheUserType] = {
find(By(this.username, name), By(this.provider, provider))
}
def findAuthUserByPrimaryKey(key: Long): Box[TheUserType] = {
find(By(this.user, key))
}
def passwordResetUrl(name: String, email: String, userId: String): String = {
find(By(this.username, name)) match {
case Full(authUser) if authUser.validated_? && authUser.email == email =>
Users.users.vend.getUserByUserId(userId) match {
case Full(u) if u.name == name && u.emailAddress == email =>
authUser.resetUniqueId().save
val resetLink = Constant.HostName+
passwordResetPath.mkString("/", "/", "/")+urlEncode(authUser.getUniqueId())
logger.warn(s"Password reset url is created for this user: $email")
// TODO Notify via email appropriate persons
resetLink
case _ => ""
}
case _ => ""
}
}
override def passwordResetXhtml = {
<div id="recover-password" tabindex="-1">
<h1>{if(ObpS.queryString.isDefined) Helper.i18n("set.your.password") else S.?("reset.your.password")}</h1>
<form action={ObpS.uri} method="post">
<div class="form-group">
<label for="password">{S.?("enter.your.new.password")}</label> <span><input id="password" class="form-control" type="password" /></span>
</div>
<div class="form-group">
<label for="repeatpassword">{S.?("repeat.your.new.password")}</label> <span><input id="repeatpassword" class="form-control" type="password" /></span>
</div>
<div class="form-group">
<input type="submit" class="btn btn-danger" />
</div>
</form>
</div>
}
/**
* Find the authUsers by author email(authUser and resourceUser are the same).
* Only search for the local database.
*/
protected def findUsersByEmailLocally(email: String): List[TheUserType] = {
val usernames: List[String] = this.getResourceUsersByEmail(email).map(_.user.name)
findAll(ByList(this.username, usernames))
}
def signupSubmitButtonValue() = getWebUiPropsValue("webui_signup_form_submit_button_value", S.?("sign.up"))
//overridden to allow redirect to loginRedirect after signup. This is mostly to allow
// loginFirst menu items to work if the user doesn't have an account. Without this,
// if a user tries to access a logged-in only page, and then signs up, they don't get redirected
// back to the proper page.
override def signup = {
val theUser: TheUserType = mutateUserOnSignup(createNewUserInstance())
val theName = signUpPath.mkString("")
//Check the internal redirect, in case for open redirect issue.
// variable redir is from loginRedirect, it is set-up in OAuthAuthorisation.scala as following code:
// val currentUrl = ObpS.uriAndQueryString.getOrElse("/")
// AuthUser.loginRedirect.set(Full(Helpers.appendParams(currentUrl, List((LogUserOutParam, "false")))))
val loginRedirectSave = loginRedirect.is
def testSignup() {
validateSignup(theUser) match {
case Nil =>
//here we check loginRedirectSave (different from implementation in super class)
val redir = loginRedirectSave match {
case Full(url) =>
loginRedirect(Empty)
url
case _ =>
//if the register page url (user_mgt/sign_up?after-signup=link-to-customer) contains the parameter
//after-signup=link-to-customer,then it will redirect to the on boarding customer page.
ObpS.param("after-signup") match {
case url if (url.equals("link-to-customer")) =>
"/add-user-auth-context-update-request"
case _ =>
homePage
}
}
if (Helper.isValidInternalRedirectUrl(redir.toString)) {
actionsAfterSignup(theUser, () => {
S.redirectTo(redir)
})
} else {
S.error(S.?(ErrorMessages.InvalidInternalRedirectUrl))
logger.info(ErrorMessages.InvalidInternalRedirectUrl + loginRedirect.get)
}
case xs =>
xs.foreach{
e => S.error(e.field.uniqueFieldId.openOrThrowException("There is no uniqueFieldId."), e.msg)
}
signupFunc(Full(innerSignup _))
}
}
def innerSignup = {
val bind = "type=submit" #> signupSubmitButton(signupSubmitButtonValue(), testSignup _)
bind(signupXhtml(theUser))
}
if(APIUtil.getPropsAsBoolValue("user_invitation.mandatory", false))
S.redirectTo("/user-invitation-info")
else
innerSignup
}
def scrambleAuthUser(userPrimaryKey: UserPrimaryKey): Box[Boolean] = tryo {
AuthUser.find(By(AuthUser.user, userPrimaryKey.value)) match {
case Full(user) =>
val scrambledUser = user.firstName(Helpers.randomString(16))
.email(Helpers.randomString(10) + "@example.com")
.username("DELETED-" + Helpers.randomString(16))
.firstName(Helpers.randomString(16))
.lastName(Helpers.randomString(16))
.password(Helpers.randomString(40))
.validated(false)
scrambledUser.save
case Empty => true // There is a resource user but no the correlated Auth user
case _ => false // Error case
}
}
def validateAuthUser(userPrimaryKey: UserPrimaryKey): Box[AuthUser] = tryo {
AuthUser.find(By(AuthUser.user, userPrimaryKey.value)) match {
case Full(user) =>
user.validated(true).saveMe()
}
}
/**
* Find a user by their unique validation token.
* This is a public wrapper for the protected findUserByUniqueId method.
*
* @param token The unique validation token (UUID string)
* @return Box containing the AuthUser if found, Empty if not found, or Failure on error
*/
def findUserByValidationToken(token: String): Box[AuthUser] = {
findUserByUniqueId(token)
}
/**
* Validate a user and reset their unique ID token.
* This is a public wrapper that combines validation and token reset.
*
* @param user The AuthUser to validate
* @return The validated AuthUser with reset unique ID
*/
def validateAndResetToken(user: AuthUser): AuthUser = {
user.validated(true).resetUniqueId().save
user
}
}