diff --git a/user-management/src/main/elm/sources/DataTypes.elm b/user-management/src/main/elm/sources/DataTypes.elm index 4113dfd7e..0cd9dde99 100644 --- a/user-management/src/main/elm/sources/DataTypes.elm +++ b/user-management/src/main/elm/sources/DataTypes.elm @@ -35,6 +35,7 @@ type alias User = type alias UsersConf = { digest : String + , authenticationBackends: List String , users : List User } @@ -63,6 +64,8 @@ type alias Model = , hashedPasswd : Bool , isValidInput : StateInput , authzToAddOnSave : List String + , providers : List String + , shouldAskPassword : Bool } type Msg @@ -85,6 +88,7 @@ type Msg | SubmitUpdatedInfos User | SubmitNewUser User | PreHashedPasswd + | CheckExtAuth Bool -- NOTIFICATIONS | ToastyMsg (Toasty.Msg Toasty.Defaults.Toast) diff --git a/user-management/src/main/elm/sources/Init.elm b/user-management/src/main/elm/sources/Init.elm index 5cb79e1f9..cb252d7a4 100644 --- a/user-management/src/main/elm/sources/Init.elm +++ b/user-management/src/main/elm/sources/Init.elm @@ -27,7 +27,7 @@ authorizations = init : { contextPath : String } -> ( Model, Cmd Msg ) init flags = let - initModel = Model flags.contextPath "" (fromList []) (fromList []) [] authorizations Toasty.initialState Closed "" "" True ValidInputs [] + initModel = Model flags.contextPath "" (fromList []) (fromList []) [] authorizations Toasty.initialState Closed "" "" True ValidInputs [] [] True in ( initModel , getUsersConf initModel diff --git a/user-management/src/main/elm/sources/JsonDecoder.elm b/user-management/src/main/elm/sources/JsonDecoder.elm index 877d68aa9..db0d1d364 100644 --- a/user-management/src/main/elm/sources/JsonDecoder.elm +++ b/user-management/src/main/elm/sources/JsonDecoder.elm @@ -22,6 +22,7 @@ decodeCurrentUsersConf : Decoder UsersConf decodeCurrentUsersConf = D.succeed UsersConf |> required "digest" D.string + |> required "authenticationBackends" (D.list <| D.string) |> required "users" (D.list <| decodeUser) decodeUser : Decoder User @@ -30,6 +31,7 @@ decodeUser = |> required "login" D.string |> required "authz" (D.list <| D.string) |> required "role" (D.list <| D.string) + --|> required "hasValidHash" D.bool decodeApiRoleCoverage : Decoder Authorization decodeApiRoleCoverage = diff --git a/user-management/src/main/elm/sources/UserManagement.elm b/user-management/src/main/elm/sources/UserManagement.elm index 7c177fc6a..05c67984f 100644 --- a/user-management/src/main/elm/sources/UserManagement.elm +++ b/user-management/src/main/elm/sources/UserManagement.elm @@ -6,7 +6,7 @@ import DataTypes exposing (Authorization, Model, Msg(..), PanelMode(..), StateIn import Dict exposing (fromList) import Http exposing (..) import Init exposing (createErrorNotification, createSuccessNotification, defaultConfig, init, subscriptions) -import List exposing (all, filter) +import List exposing (all, filter, head) import String exposing (isEmpty) import Toasty import View exposing (view) @@ -27,6 +27,11 @@ getUser username users = Nothing -> Nothing +takeFirstExtProvider: List String -> Maybe String +takeFirstExtProvider providers = + head (filter (\p -> p /= "rootAdmin" || p /= "file") providers) + + update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of @@ -37,10 +42,14 @@ update msg model = case result of Ok u -> let + --isExtProviderExists = + -- case u.authenticationBackends of + -- Just p -> p + -- Nothing -> recordUser = List.map (\x -> (x.login, (Authorization x.authz x.role))) u.users newModel = - { model | users = fromList recordUser, digest = u.digest} + { model | users = fromList recordUser, digest = u.digest, providers = u.authenticationBackends} in ( newModel, getRoleConf model ) @@ -153,9 +162,26 @@ update msg model = "" -- This case shouldn't happen since we must be in EditMod, it will invalidate the user in the file. else model.login + newPassword = + if model.shouldAskPassword then + case (takeFirstExtProvider model.providers) of + Just p -> p + Nothing -> "" + else + model.password in - ({model | authzToAddOnSave = [], password = "", login = "", panelMode = Closed}, updateUser model u.login model.password { u | login = model.login}) + ({model | authzToAddOnSave = [], password = "", login = "", panelMode = Closed}, updateUser model u.login newPassword { u | login = model.login}) SubmitNewUser u -> + --let + -- extProviders = filter (\p -> p /= "rootAdmin" || p /= "file") model.providers + -- newModel = + -- if (model.isRadioExtAuthOn) then + -- case (head extProviders) of + -- Just p -> ({model | password = p, hashedPasswd = True}) + -- Nothing -> model + -- else + -- model + --in if (isEmpty u.login && isEmpty model.password) then ({model | isValidInput = InvalidInputs}, Cmd.none) else if (isEmpty u.login) then @@ -170,6 +196,22 @@ update msg model = ({model | password = "",hashedPasswd = False}, Cmd.none) else ({model | password = "", hashedPasswd = True}, Cmd.none) + CheckExtAuth isChecked -> + let + extProvider = + case (takeFirstExtProvider model.providers) of + Just p -> p + Nothing -> "" + in + if isChecked then + ({model | password = extProvider, shouldAskPassword = False}, Cmd.none) + else + if (isEmpty extProvider) then + ({model | shouldAskPassword = True}, Cmd.none) + else + ({model | shouldAskPassword = False}, Cmd.none) + + processApiError : Error -> Model -> ( Model, Cmd Msg ) processApiError err model = diff --git a/user-management/src/main/elm/sources/View.elm b/user-management/src/main/elm/sources/View.elm index 69e7ee0d9..760ffff76 100644 --- a/user-management/src/main/elm/sources/View.elm +++ b/user-management/src/main/elm/sources/View.elm @@ -8,10 +8,11 @@ module View exposing (..) import ApiCalls exposing (deleteUser) import DataTypes exposing (PanelMode(..), Model, Msg(..), RoleConf, Roles, StateInput(..), User, Username, Users, UsersConf) +import Debug exposing (log) import Dict exposing (keys) -import Html exposing (Html, a, br, button, div, h2, h3, h4, h5, i, input, p, span, text) -import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder, required, type_, value) -import Html.Events exposing (onClick, onInput) +import Html exposing (Html, a, br, button, div, h2, h3, h4, h5, i, input, label, p, span, text) +import Html.Attributes exposing (attribute, checked, class, disabled, for, href, id, name, placeholder, required, type_, value) +import Html.Events exposing (onCheck, onClick, onInput) import Init exposing (defaultConfig) import List exposing (all, any, filter, map) import String exposing (isEmpty) @@ -37,17 +38,18 @@ hashPasswordMenu : Model -> Html Msg hashPasswordMenu model = let hashPasswdIsActivate = - if (model.hashedPasswd == True) then - "active" + if (model.hashedPasswd == True && model.shouldAskPassword) then + "active " else "" clearPasswdIsActivate = - if (model.hashedPasswd == False) then - "active" + if (model.hashedPasswd == False && model.shouldAskPassword) then + "active " else "" + isDeactivate = if model.shouldAskPassword then "" else "btn-group-deactivate " in - div [class "btn-group", attribute "role" "group"] + div [class ("btn-group " ++ isDeactivate), attribute "role" "group"] [ a [class ("btn btn-default " ++ hashPasswdIsActivate), onClick PreHashedPasswd][text "Enter pre-hashed value"] , a [class ("btn btn-default " ++ clearPasswdIsActivate), onClick PreHashedPasswd][text "Password to hash"] @@ -71,6 +73,8 @@ displayRightPanelAddUser model = if (model.isValidInput == InvalidPassword || model.isValidInput == InvalidInputs) then "invalid-password" else "valid-input" _ -> "valid-input" + externalProviders = filter (\p -> (p /= "file" && p /= "rootAdmin")) model.providers + in div [class "panel-wrap"] [ @@ -78,6 +82,7 @@ displayRightPanelAddUser model = [ button [class "btn btn-sm btn-outline-secondary", onClick DeactivatePanel][text "Close"] , div[class "card-header"][h2 [class "title-username"] [text "Create User"]] + , displayCheckBoxExtAuth (log "providersFIlter" externalProviders) , div [] [ input [id emptyUsername, class "form-control username-input", type_ "text", placeholder "Username", onInput Login, required True] [] @@ -102,6 +107,17 @@ displayDropdownRoleList roles = in div [class "dropdown-content"] tokens +displayCheckBoxExtAuth : List String -> Html Msg +displayCheckBoxExtAuth extProviders = + let + isDisabled = List.isEmpty extProviders + classMsgColor = if isDisabled then "disable-msg-auth" else "enable-msg-auth" + in + div [class "ext-auth-wrapper"] + [ + input [type_ "checkbox", id "extAuthEnabled", disabled isDisabled, onCheck (\checkstatus -> CheckExtAuth checkstatus)][] + , label [for "extAuthEnabled", class ("label-msg-ext-auth " ++ classMsgColor)][text "Use external authentication for this user"] + ] displayRightPanel : Model -> Html Msg displayRightPanel model = @@ -116,6 +132,9 @@ displayRightPanel model = else button [class "addBtn"][i [class "fa fa-plus"][]] isHidden = if model.hashedPasswd then "text" else "password" + externalProviders = filter (\p -> (p /= "file" && p /= "rootAdmin")) model.providers + classDeactivatePasswdInput = if (log "input pass " model.shouldAskPassword) then "" else " input-passwd-deactivate " + in div [class "panel-wrap"] [ @@ -126,9 +145,10 @@ displayRightPanel model = h2 [class "title-username"] [text user.login] , button [class "btn btn-sm btn-outline-secondary", onClick DeactivatePanel][text "Close"] ] + , displayCheckBoxExtAuth (log "providersFIlter" externalProviders) , input [class "form-control username-input", type_ "text", placeholder "New Username", onInput Login] [] , hashPasswordMenu model - , input [class "form-control", type_ isHidden, placeholder "New Password", onInput Password, attribute "autocomplete" "new-password", value model.password ] [] + , input [class ("form-control " ++ classDeactivatePasswdInput), type_ isHidden, placeholder "New Password", onInput Password, attribute "autocomplete" "new-password", value model.password] [] , h4 [class "role-title"][text "Roles"] , div [class "role-management-wrapper"] [ diff --git a/user-management/src/main/resources/toserve/usermanagement/user-management.css b/user-management/src/main/resources/toserve/usermanagement/user-management.css index b20033625..f906923c8 100644 --- a/user-management/src/main/resources/toserve/usermanagement/user-management.css +++ b/user-management/src/main/resources/toserve/usermanagement/user-management.css @@ -460,3 +460,33 @@ h3 { color : #f08004; } +.ext-auth-wrapper { + margin-top : 20px; + margin-bottom : 40px; + white-space: nowrap; +} + +.label-msg-ext-auth { + font-weight: normal; + margin-left : 10px; +} + +.disable-msg-auth { + color: #bbbbbb; +} + +.enable-msg-auth { + color: black; +} + +.btn-group-deactivate { + cursor: not-allowed; + opacity: 0.4; + background: #CCC; +} + +.input-passwd-deactivate { + cursor: not-allowed; + opacity: 0.4; + background: #CCC; +} diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala index 49335fad0..c6e46373f 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/DataTypes.scala @@ -38,6 +38,7 @@ package com.normation.plugins.usermanagement import bootstrap.liftweb.PasswordEncoder +import bootstrap.liftweb.RudderConfig import bootstrap.liftweb.UserDetailList import com.normation.rudder.Role import com.normation.rudder.Role.Custom @@ -53,19 +54,13 @@ object UserManagementLogger extends Logger { override protected def _logger = LoggerFactory.getLogger("usermanagement") } - object Serialisation { implicit class AuthConfigSer(auth: UserDetailList) { def toJson: JValue = { - val encoder = auth.encoder match { - case PasswordEncoder.MD5 => "MD5" - case PasswordEncoder.SHA1 => "SHA-1" - case PasswordEncoder.SHA256 => "SHA-256" - case PasswordEncoder.SHA512 => "SHA-512" - case PasswordEncoder.BCRYPT => "BCRYPT" - case _ => "plain text" - } + val encoder: String = PassEncoderToString(auth) + val authBackendsProvider = RudderConfig.authenticationProviders.getConfiguredProviders.map(_.name).toSet + val jUser = auth.users.map { case(_,u) => @@ -74,21 +69,34 @@ object Serialisation { case _ => true } JsonUser( - u.getUsername - , if (custom.isEmpty) Set.empty else custom.head.rights.displayAuthorizations.split(",").toSet + u.getUsername + , if (custom.isEmpty) Set.empty else custom.head.rights.displayAuthorizations.split(",").toSet , rs.map(_.name) + , UserOrigin.verifyHash(encoder, u.getPassword) ) }.toList.sortBy( _.login ) - val json = JsonAuthConfig(encoder, jUser) + val json = JsonAuthConfig(encoder, authBackendsProvider, jUser) import net.liftweb.json._ implicit val formats = S.formats(NoTypeHints) Extraction.decompose(json) } } + + def PassEncoderToString(auth: UserDetailList): String = { + auth.encoder match { + case PasswordEncoder.MD5 => "MD5" + case PasswordEncoder.SHA1 => "SHA-1" + case PasswordEncoder.SHA256 => "SHA-256" + case PasswordEncoder.SHA512 => "SHA-512" + case PasswordEncoder.BCRYPT => "BCRYPT" + case _ => "plain text" + } + } } final case class JsonAuthConfig( digest: String + , authenticationBackends: Set[String] , users : List[JsonUser] ) @@ -96,4 +104,5 @@ final case class JsonUser( login: String , authz: Set[String] , role: Set[String] + , hasValidHash: Boolean ) diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala index 33cfa4ec2..13e0ac96d 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/UserManagementService.scala @@ -2,23 +2,53 @@ package com.normation.plugins.usermanagement import better.files.Dsl.SymbolicOperations import better.files.File -import bootstrap.liftweb.{PasswordEncoder, UserConfigFileError, UserFile, UserFileProcessing} +import bootstrap.liftweb.PasswordEncoder +import bootstrap.liftweb.PasswordEncoder.DigestEncoder +import bootstrap.liftweb.UserConfigFileError +import bootstrap.liftweb.UserFile +import bootstrap.liftweb.UserFileProcessing import com.normation.plugins.usermanagement.UserManagementIO.getUserFilePath import com.normation.rudder.Role.Custom import com.normation.rudder.repository.xml.RudderPrettyPrinter -import com.normation.rudder.{AuthorizationType, Rights, Role, RoleToRights} -import net.liftweb.common.{Box, Failure, Full} +import com.normation.rudder.AuthorizationType +import com.normation.rudder.Rights +import com.normation.rudder.Role +import com.normation.rudder.RoleToRights +import net.liftweb.common.Box +import net.liftweb.common.Failure +import net.liftweb.common.Full import net.liftweb.util.Helpers.tryo import org.springframework.core.io.{ClassPathResource => CPResource} + import scala.xml.parsing.ConstructingParser -import scala.xml.transform.{RewriteRule, RuleTransformer} -import scala.xml.{Elem, Node, NodeSeq} +import scala.xml.transform.RewriteRule +import scala.xml.transform.RuleTransformer +import scala.xml.Elem +import scala.xml.Node +import scala.xml.NodeSeq +case class UserFileInfo(userOrigin: List[UserOrigin], digest: String) +case class UserOrigin(user: User, hashValidHash: Boolean) case class User(username: String, password: String, role: Set[String]) { def toNode: Node = } +object UserOrigin { + def verifyHash(hashType: String, hash: String) = { + //$2[aby]$[cost]$[22 character salt][31 character hash] + val bcryptReg = "^\\$2[aby]?\\$[\\d]+\\$[./A-Za-z0-9]{53}$".r + hashType.toLowerCase match { + case "sha" | "sha1" => hash.matches("^[a-fA-F0-9]{40}$") + case "sha256" | "sha-256" => hash.matches("^[a-fA-F0-9]{64}$") + case "sha512" | "sha-512" => hash.matches("^[a-fA-F0-9]{128}$") + case "md5" => hash.matches("^[a-fA-F0-9]{32}$") + case "bcrypt" => hash.matches(bcryptReg.regex) + case _ => false + } + } +} + object UserManagementIO { def replaceXml(currentXml: NodeSeq, newXml: Node, file: File): Box[File] = { @@ -135,7 +165,8 @@ object UserManagementService { } } - def add(newUser: User, isPreHashed: Boolean): Box[User] = { + def add(newUser: User, isPreHashed: Boolean, isExtAuth: Boolean): Box[User] = { + val newPasswd = if (isExtAuth) "EXTERNAL-USER-AUTHENTICATION" else newUser.password getUserFilePath match { case Left(err) => Failure(err.msg) @@ -146,7 +177,7 @@ object UserManagementService { case e: Elem => val newXml = if (isPreHashed) - e.copy(child = e.child ++ newUser.copy(password = newUser.password).toNode) + e.copy(child = e.child ++ newUser.copy(password = newPasswd).toNode) else e.copy(child = e.child ++ newUser.copy(password = getHash((userXML \\ "authentication" \ "@hash").text).encode(newUser.password)).toNode) UserManagementIO.replaceXml(userXML, newXml, file) @@ -209,4 +240,28 @@ object UserManagementService { } } } + + def getAll: Box[UserFileInfo] = { + getUserFilePath match { + case Left(err) => + Failure(err.msg) + case Right(file) => + tryo(ConstructingParser.fromFile(file.toJava, preserveWS = true)).flatMap { parsedFile => + val userXML = parsedFile.document.children + (userXML \\ "authentication").head match { + case e: Elem => + val digest = (userXML \\ "authentication" \ "@hash").text.toUpperCase + val users = e.map(u => { + val password = (u \ "@password").text + val user = User((u \ "@name").text, (u \ "@password").text, (u \ "@role").map(_.text).toSet) + val hasValidHash = UserOrigin.verifyHash(digest, password) + UserOrigin(user, hasValidHash) + }).toList + Full(UserFileInfo(users, digest)) + case _ => + Failure(s"Wrong formatting : ${file.path}") + } + } + } + } } diff --git a/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala b/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala index f2dbc5012..0be647a6e 100644 --- a/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala +++ b/user-management/src/main/scala/com/normation/plugins/usermanagement/api/UserManagementApi.scala @@ -38,6 +38,7 @@ package com.normation.plugins.usermanagement.api import bootstrap.liftweb.FileUserDetailListProvider +import bootstrap.liftweb.UserDetailList import com.normation.plugins.usermanagement.{Serialization, User, UserManagementService} import com.normation.rudder.api.HttpAction.{DELETE, GET, POST} import com.normation.rudder.repository.json.DataExtractor.CompleteJson @@ -51,6 +52,8 @@ import net.liftweb.json.JsonDSL._ import net.liftweb.json.{JValue, NoTypeHints} import sourcecode.Line +import scala.tools.nsc.interactive.Pickler.TildeDecorator + /* * This file contains the internal API used to discuss with the JS application. * @@ -134,6 +137,10 @@ class UserManagementApiImpl( CompleteJson.extractJsonBoolean(json, "isPreHashed") } + def extractIsExtAuth(json: JValue): Box[Boolean] = { + CompleteJson.extractJsonBoolean(json, "isExtAuth") + } + override def getLiftEndpoints(): List[LiftApiModule] = { UserManagementApi.endpoints.map { case UserManagementApi.GetUserInfo => GetUserInfo @@ -213,8 +220,9 @@ class UserManagementApiImpl( json <- req.json ?~! "No JSON data sent" user <- extractUser(json) isPreHashed <- extractIsHashed(json) + isPreHashed <- extractIsExtAuth(json) checkExistence <- if (userService.authConfig.users.keySet contains user.username) Failure(s"User '${user.username}' already exists") else Full("ok") - added <- UserManagementService.add(user, isPreHashed) + added <- UserManagementService.add(user, isPreHashed, isPreHashed) _ <- reload() } yield { @@ -251,7 +259,7 @@ class UserManagementApiImpl( val value: Box[JValue] = for { json <- req.json ?~! "No JSON data sent" user <- extractUser(json) - isPreHashed <- extractIsHashed(json) + isPreHashed <- extractIsHashed(json) checkExistence <- if (!(userService.authConfig.users.keySet contains id)) Failure(s"'$id' does not exists") else Full("ok") updated <- UserManagementService.update(id, user, isPreHashed) _ <- reload()