mirror of
https://github.com/gitbucket/gitbucket.git
synced 2025-11-07 14:05:52 +01:00
Add OpenID Connect authentication feature
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import com.typesafe.sbt.license.{LicenseInfo, DepModuleInfo}
|
import com.typesafe.sbt.license.{DepModuleInfo, LicenseInfo}
|
||||||
import com.typesafe.sbt.pgp.PgpKeys._
|
import com.typesafe.sbt.pgp.PgpKeys._
|
||||||
|
|
||||||
val Organization = "io.github.gitbucket"
|
val Organization = "io.github.gitbucket"
|
||||||
@@ -55,6 +55,7 @@ libraryDependencies ++= Seq(
|
|||||||
"com.enragedginger" %% "akka-quartz-scheduler" % "1.6.1-akka-2.5.x" exclude("c3p0","c3p0"),
|
"com.enragedginger" %% "akka-quartz-scheduler" % "1.6.1-akka-2.5.x" exclude("c3p0","c3p0"),
|
||||||
"net.coobird" % "thumbnailator" % "0.4.8",
|
"net.coobird" % "thumbnailator" % "0.4.8",
|
||||||
"com.github.zafarkhaja" % "java-semver" % "0.9.0",
|
"com.github.zafarkhaja" % "java-semver" % "0.9.0",
|
||||||
|
"com.nimbusds" % "oauth2-oidc-sdk" % "5.45",
|
||||||
"org.eclipse.jetty" % "jetty-webapp" % JettyVersion % "provided",
|
"org.eclipse.jetty" % "jetty-webapp" % JettyVersion % "provided",
|
||||||
"javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
|
"javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
|
||||||
"junit" % "junit" % "4.12" % "test",
|
"junit" % "junit" % "4.12" % "test",
|
||||||
@@ -127,8 +128,8 @@ libraryDependencies ++= Seq(
|
|||||||
|
|
||||||
val executableKey = TaskKey[File]("executable")
|
val executableKey = TaskKey[File]("executable")
|
||||||
executableKey := {
|
executableKey := {
|
||||||
import java.util.jar.{ Manifest => JarManifest }
|
|
||||||
import java.util.jar.Attributes.{Name => AttrName}
|
import java.util.jar.Attributes.{Name => AttrName}
|
||||||
|
import java.util.jar.{Manifest => JarManifest}
|
||||||
|
|
||||||
val workDir = Keys.target.value / "executable"
|
val workDir = Keys.target.value / "executable"
|
||||||
val warName = Keys.name.value + ".war"
|
val warName = Keys.name.value + ".war"
|
||||||
|
|||||||
@@ -28,4 +28,12 @@
|
|||||||
</createTable>
|
</createTable>
|
||||||
<addPrimaryKey constraintName="IDX_RELEASE_ASSET_PK" tableName="RELEASE_ASSET" columnNames="USER_NAME, REPOSITORY_NAME, TAG, FILE_NAME"/>
|
<addPrimaryKey constraintName="IDX_RELEASE_ASSET_PK" tableName="RELEASE_ASSET" columnNames="USER_NAME, REPOSITORY_NAME, TAG, FILE_NAME"/>
|
||||||
<addForeignKeyConstraint constraintName="IDX_RELEASE_ASSET_FK1" baseTableName="RELEASE_ASSET" baseColumnNames="USER_NAME, REPOSITORY_NAME, TAG" referencedTableName="RELEASE" referencedColumnNames="USER_NAME, REPOSITORY_NAME, TAG"/>
|
<addForeignKeyConstraint constraintName="IDX_RELEASE_ASSET_FK1" baseTableName="RELEASE_ASSET" baseColumnNames="USER_NAME, REPOSITORY_NAME, TAG" referencedTableName="RELEASE" referencedColumnNames="USER_NAME, REPOSITORY_NAME, TAG"/>
|
||||||
|
|
||||||
|
<createTable tableName="ACCOUNT_FEDERATION">
|
||||||
|
<column name="ISSUER" type="varchar(100)" nullable="false"/>
|
||||||
|
<column name="SUBJECT" type="varchar(100)" nullable="false"/>
|
||||||
|
<column name="USER_NAME" type="varchar(100)" nullable="false"/>
|
||||||
|
</createTable>
|
||||||
|
<addPrimaryKey constraintName="IDX_ACCOUNT_FEDERATION_PK" tableName="ACCOUNT_FEDERATION" columnNames="ISSUER, SUBJECT"/>
|
||||||
|
<addForeignKeyConstraint constraintName="IDX_ACCOUNT_FEDERATION_FK0" baseTableName="ACCOUNT_FEDERATION" baseColumnNames="USER_NAME" referencedTableName="ACCOUNT" referencedColumnNames="USER_NAME"/>
|
||||||
</changeSet>
|
</changeSet>
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
package gitbucket.core.controller
|
package gitbucket.core.controller
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
import com.nimbusds.oauth2.sdk.id.State
|
||||||
|
import com.nimbusds.openid.connect.sdk.Nonce
|
||||||
import gitbucket.core.helper.xml
|
import gitbucket.core.helper.xml
|
||||||
import gitbucket.core.model.Account
|
import gitbucket.core.model.Account
|
||||||
import gitbucket.core.service._
|
import gitbucket.core.service._
|
||||||
import gitbucket.core.util.Implicits._
|
import gitbucket.core.util.Implicits._
|
||||||
import gitbucket.core.util.SyntaxSugars._
|
import gitbucket.core.util.SyntaxSugars._
|
||||||
import gitbucket.core.util.{Keys, LDAPUtil, ReferrerAuthenticator, UsersAuthenticator}
|
import gitbucket.core.util.{Keys, LDAPUtil, ReferrerAuthenticator, UsersAuthenticator}
|
||||||
import org.scalatra.forms._
|
|
||||||
import org.scalatra.Ok
|
import org.scalatra.Ok
|
||||||
|
import org.scalatra.forms._
|
||||||
|
|
||||||
|
|
||||||
class IndexController extends IndexControllerBase
|
class IndexController extends IndexControllerBase
|
||||||
with RepositoryService with ActivityService with AccountService with RepositorySearchService with IssuesService
|
with RepositoryService
|
||||||
with UsersAuthenticator with ReferrerAuthenticator
|
with ActivityService
|
||||||
|
with AccountService
|
||||||
|
with RepositorySearchService
|
||||||
|
with IssuesService
|
||||||
|
with UsersAuthenticator
|
||||||
|
with ReferrerAuthenticator
|
||||||
|
with OpenIDConnectService
|
||||||
|
|
||||||
|
|
||||||
trait IndexControllerBase extends ControllerBase {
|
trait IndexControllerBase extends ControllerBase {
|
||||||
self: RepositoryService with ActivityService with AccountService with RepositorySearchService
|
self: RepositoryService
|
||||||
with UsersAuthenticator with ReferrerAuthenticator =>
|
with ActivityService
|
||||||
|
with AccountService
|
||||||
|
with RepositorySearchService
|
||||||
|
with UsersAuthenticator
|
||||||
|
with ReferrerAuthenticator
|
||||||
|
with OpenIDConnectService =>
|
||||||
|
|
||||||
case class SignInForm(userName: String, password: String, hash: Option[String])
|
case class SignInForm(userName: String, password: String, hash: Option[String])
|
||||||
|
|
||||||
@@ -55,14 +70,62 @@ trait IndexControllerBase extends ControllerBase {
|
|||||||
|
|
||||||
post("/signin", signinForm){ form =>
|
post("/signin", signinForm){ form =>
|
||||||
authenticate(context.settings, form.userName, form.password) match {
|
authenticate(context.settings, form.userName, form.password) match {
|
||||||
case Some(account) => signin(account, form.hash)
|
case Some(account) =>
|
||||||
case None => {
|
flash.get(Keys.Flash.Redirect) match {
|
||||||
|
case Some(redirectUrl: String) => signin(account, redirectUrl + form.hash.getOrElse(""))
|
||||||
|
case _ => signin(account)
|
||||||
|
}
|
||||||
|
case None =>
|
||||||
flash += "userName" -> form.userName
|
flash += "userName" -> form.userName
|
||||||
flash += "password" -> form.password
|
flash += "password" -> form.password
|
||||||
flash += "error" -> "Sorry, your Username and/or Password is incorrect. Please try again."
|
flash += "error" -> "Sorry, your Username and/or Password is incorrect. Please try again."
|
||||||
redirect("/signin")
|
redirect("/signin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate an OpenID Connect authentication request.
|
||||||
|
*/
|
||||||
|
post("/signin/oidc") {
|
||||||
|
context.settings.oidc.map { oidc =>
|
||||||
|
val redirectURI = new URI(s"$baseUrl/signin/oidc")
|
||||||
|
val authenticationRequest = createOIDCAuthenticationRequest(oidc.issuer, oidc.clientID, redirectURI)
|
||||||
|
session.setAttribute(Keys.Session.OidcState, authenticationRequest.getState)
|
||||||
|
session.setAttribute(Keys.Session.OidcNonce, authenticationRequest.getNonce)
|
||||||
|
session.setAttribute(Keys.Session.OidcRedirectBackURI,
|
||||||
|
flash.get(Keys.Flash.Redirect) match {
|
||||||
|
case Some(redirectBackURI: String) => redirectBackURI + params.getOrElse("hash", "")
|
||||||
|
case _ => "/"
|
||||||
|
})
|
||||||
|
redirect(authenticationRequest.toURI.toString)
|
||||||
|
} getOrElse {
|
||||||
|
NotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an OpenID Connect authentication response.
|
||||||
|
*/
|
||||||
|
get("/signin/oidc") {
|
||||||
|
context.settings.oidc.map { oidc =>
|
||||||
|
val redirectURI = new URI(s"$baseUrl/signin/oidc")
|
||||||
|
Seq(Keys.Session.OidcState, Keys.Session.OidcNonce, Keys.Session.OidcRedirectBackURI).map(session.get(_)) match {
|
||||||
|
case Seq(Some(state: State), Some(nonce: Nonce), Some(redirectBackURI: String)) =>
|
||||||
|
authenticate(params, redirectURI, state, nonce, oidc) map { account =>
|
||||||
|
signin(account, redirectBackURI)
|
||||||
|
} orElse {
|
||||||
|
flash += "error" -> "Sorry, authentication failed. Please try again."
|
||||||
|
session.invalidate()
|
||||||
|
redirect("/signin")
|
||||||
|
}
|
||||||
|
case _ =>
|
||||||
|
flash += "error" -> "Sorry, something wrong. Please try again."
|
||||||
|
session.invalidate()
|
||||||
|
redirect("/signin")
|
||||||
|
}
|
||||||
|
} getOrElse {
|
||||||
|
NotFound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/signout"){
|
get("/signout"){
|
||||||
@@ -87,7 +150,7 @@ trait IndexControllerBase extends ControllerBase {
|
|||||||
/**
|
/**
|
||||||
* Set account information into HttpSession and redirect.
|
* Set account information into HttpSession and redirect.
|
||||||
*/
|
*/
|
||||||
private def signin(account: Account, hash: Option[String]) = {
|
private def signin(account: Account, redirectUrl: String = "/") = {
|
||||||
session.setAttribute(Keys.Session.LoginAccount, account)
|
session.setAttribute(Keys.Session.LoginAccount, account)
|
||||||
updateLastLoginDate(account.userName)
|
updateLastLoginDate(account.userName)
|
||||||
|
|
||||||
@@ -95,14 +158,10 @@ trait IndexControllerBase extends ControllerBase {
|
|||||||
redirect("/" + account.userName + "/_edit")
|
redirect("/" + account.userName + "/_edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
flash.get(Keys.Flash.Redirect).asInstanceOf[Option[String]].map { redirectUrl =>
|
|
||||||
if (redirectUrl.stripSuffix("/") == request.getContextPath) {
|
if (redirectUrl.stripSuffix("/") == request.getContextPath) {
|
||||||
redirect("/")
|
redirect("/")
|
||||||
} else {
|
} else {
|
||||||
redirect(redirectUrl + hash.getOrElse(""))
|
redirect(redirectUrl)
|
||||||
}
|
|
||||||
}.getOrElse {
|
|
||||||
redirect("/")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ package gitbucket.core.controller
|
|||||||
|
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
import gitbucket.core.admin.html
|
|
||||||
import gitbucket.core.service.{AccountService, RepositoryService, SystemSettingsService}
|
|
||||||
import gitbucket.core.util.{AdminAuthenticator, Mailer}
|
|
||||||
import gitbucket.core.ssh.SshServer
|
|
||||||
import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
|
|
||||||
import SystemSettingsService._
|
|
||||||
import gitbucket.core.util.Implicits._
|
|
||||||
import gitbucket.core.util.SyntaxSugars._
|
|
||||||
import gitbucket.core.util.Directory._
|
|
||||||
import gitbucket.core.util.StringUtil._
|
|
||||||
import org.scalatra.forms._
|
|
||||||
import org.apache.commons.io.IOUtils
|
|
||||||
import org.scalatra.i18n.Messages
|
|
||||||
import com.github.zafarkhaja.semver.{Version => Semver}
|
import com.github.zafarkhaja.semver.{Version => Semver}
|
||||||
import gitbucket.core.GitBucketCoreModule
|
import gitbucket.core.GitBucketCoreModule
|
||||||
import org.scalatra._
|
import gitbucket.core.admin.html
|
||||||
|
import gitbucket.core.plugin.{PluginInfoBase, PluginRegistry, PluginRepository}
|
||||||
|
import gitbucket.core.service.SystemSettingsService._
|
||||||
|
import gitbucket.core.service.{AccountService, RepositoryService}
|
||||||
|
import gitbucket.core.ssh.SshServer
|
||||||
|
import gitbucket.core.util.Directory._
|
||||||
|
import gitbucket.core.util.Implicits._
|
||||||
|
import gitbucket.core.util.StringUtil._
|
||||||
|
import gitbucket.core.util.SyntaxSugars._
|
||||||
|
import gitbucket.core.util.{AdminAuthenticator, Mailer}
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
import org.json4s.jackson.Serialization
|
import org.json4s.jackson.Serialization
|
||||||
|
import org.scalatra._
|
||||||
|
import org.scalatra.forms._
|
||||||
|
import org.scalatra.i18n.Messages
|
||||||
|
|
||||||
import scala.collection.JavaConverters._
|
import scala.collection.JavaConverters._
|
||||||
import scala.collection.mutable.ListBuffer
|
import scala.collection.mutable.ListBuffer
|
||||||
@@ -70,6 +70,13 @@ trait SystemSettingsControllerBase extends AccountManagementControllerBase {
|
|||||||
"ssl" -> trim(label("Enable SSL", optional(boolean()))),
|
"ssl" -> trim(label("Enable SSL", optional(boolean()))),
|
||||||
"keystore" -> trim(label("Keystore", optional(text())))
|
"keystore" -> trim(label("Keystore", optional(text())))
|
||||||
)(Ldap.apply)),
|
)(Ldap.apply)),
|
||||||
|
"oidcAuthentication" -> trim(label("OIDC", boolean())),
|
||||||
|
"oidc" -> optionalIfNotChecked("oidcAuthentication", mapping(
|
||||||
|
"issuer" -> trim(label("Issuer", text(required))),
|
||||||
|
"clientID" -> trim(label("Client ID", text(required))),
|
||||||
|
"clientSecret" -> trim(label("Client secret", text(required))),
|
||||||
|
"jwsAlgorithm" -> trim(label("Signature algorithm", optional(text())))
|
||||||
|
)(OIDC.apply)),
|
||||||
"skinName" -> trim(label("AdminLTE skin name", text(required)))
|
"skinName" -> trim(label("AdminLTE skin name", text(required)))
|
||||||
)(SystemSettings.apply).verifying { settings =>
|
)(SystemSettings.apply).verifying { settings =>
|
||||||
Vector(
|
Vector(
|
||||||
|
|||||||
19
src/main/scala/gitbucket/core/model/AccountFederation.scala
Normal file
19
src/main/scala/gitbucket/core/model/AccountFederation.scala
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package gitbucket.core.model
|
||||||
|
|
||||||
|
trait AccountFederationComponent { self: Profile =>
|
||||||
|
import profile.api._
|
||||||
|
|
||||||
|
lazy val AccountFederations = TableQuery[AccountFederations]
|
||||||
|
|
||||||
|
class AccountFederations(tag: Tag) extends Table[AccountFederation](tag, "ACCOUNT_FEDERATION") {
|
||||||
|
val issuer = column[String]("ISSUER")
|
||||||
|
val subject = column[String]("SUBJECT")
|
||||||
|
val userName = column[String]("USER_NAME")
|
||||||
|
def * = (issuer, subject, userName) <> (AccountFederation.tupled, AccountFederation.unapply)
|
||||||
|
|
||||||
|
def byPrimaryKey(issuer: String, subject: String): Rep[Boolean] =
|
||||||
|
(this.issuer === issuer.bind) && (this.subject === subject.bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case class AccountFederation(issuer: String, subject: String, userName: String)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package gitbucket.core.model
|
package gitbucket.core.model
|
||||||
|
|
||||||
import gitbucket.core.util.DatabaseConfig
|
|
||||||
import com.github.takezoe.slick.blocking.BlockingJdbcProfile
|
import com.github.takezoe.slick.blocking.BlockingJdbcProfile
|
||||||
|
import gitbucket.core.util.DatabaseConfig
|
||||||
|
|
||||||
trait Profile {
|
trait Profile {
|
||||||
val profile: BlockingJdbcProfile
|
val profile: BlockingJdbcProfile
|
||||||
@@ -61,6 +61,7 @@ trait CoreProfile extends ProfileProvider with Profile
|
|||||||
with RepositoryWebHookEventComponent
|
with RepositoryWebHookEventComponent
|
||||||
with AccountWebHookComponent
|
with AccountWebHookComponent
|
||||||
with AccountWebHookEventComponent
|
with AccountWebHookEventComponent
|
||||||
|
with AccountFederationComponent
|
||||||
with ProtectedBranchComponent
|
with ProtectedBranchComponent
|
||||||
with DeployKeyComponent
|
with DeployKeyComponent
|
||||||
with ReleaseComponent
|
with ReleaseComponent
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package gitbucket.core.service
|
||||||
|
|
||||||
|
import gitbucket.core.model.Profile.profile.blockingApi._
|
||||||
|
import gitbucket.core.model.Profile.{AccountFederations, Accounts}
|
||||||
|
import gitbucket.core.model.{Account, AccountFederation}
|
||||||
|
import gitbucket.core.util.SyntaxSugars.~
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
trait AccountFederationService extends AccountService {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(classOf[AccountFederationService])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a user account federated with OIDC or SAML IdP.
|
||||||
|
*
|
||||||
|
* @param issuer Issuer
|
||||||
|
* @param subject Subject
|
||||||
|
* @param mailAddress Mail address
|
||||||
|
* @param preferredUserName Username (if this is none, username will be generated from the mail address)
|
||||||
|
* @param fullName Fullname (defaults to username)
|
||||||
|
* @return Account
|
||||||
|
*/
|
||||||
|
def getOrCreateFederatedUser(issuer: String,
|
||||||
|
subject: String,
|
||||||
|
mailAddress: String,
|
||||||
|
preferredUserName: Option[String],
|
||||||
|
fullName: Option[String])(implicit s: Session): Option[Account] =
|
||||||
|
getAccountByFederation(issuer, subject) match {
|
||||||
|
case Some(account) if !account.isRemoved =>
|
||||||
|
Some(account)
|
||||||
|
case Some(account) =>
|
||||||
|
logger.info(s"Federated user found but disabled: userName=${account.userName}, isRemoved=${account.isRemoved}")
|
||||||
|
None
|
||||||
|
case None =>
|
||||||
|
findAvailableUserName(preferredUserName, mailAddress) flatMap { userName =>
|
||||||
|
createAccount(userName, "", fullName.getOrElse(userName), mailAddress, isAdmin = false, None, None)
|
||||||
|
createAccountFederation(issuer, subject, userName)
|
||||||
|
getAccountByUserName(userName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def extractSafeStringForUserName(s: String) = """^[a-zA-Z0-9][a-zA-Z0-9\-_.]*""".r.findPrefixOf(s)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an available username from the preferred username or mail address.
|
||||||
|
*
|
||||||
|
* @param mailAddress Mail address
|
||||||
|
* @param preferredUserName Username
|
||||||
|
* @return Available username
|
||||||
|
*/
|
||||||
|
private def findAvailableUserName(preferredUserName: Option[String], mailAddress: String)(implicit s: Session): Option[String] = {
|
||||||
|
preferredUserName.flatMap(n => extractSafeStringForUserName(n)).orElse(extractSafeStringForUserName(mailAddress)) match {
|
||||||
|
case Some(safeUserName) =>
|
||||||
|
getAccountByUserName(safeUserName, includeRemoved = true) match {
|
||||||
|
case None => Some(safeUserName)
|
||||||
|
case Some(_) =>
|
||||||
|
logger.info(s"User ($safeUserName) already exists. preferredUserName=$preferredUserName, mailAddress=$mailAddress")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
case None =>
|
||||||
|
logger.info(s"Could not extract username from preferredUserName=$preferredUserName, mailAddress=$mailAddress")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def getAccountByFederation(issuer: String, subject: String)(implicit s: Session): Option[Account] =
|
||||||
|
AccountFederations.filter(_.byPrimaryKey(issuer, subject))
|
||||||
|
.join(Accounts).on { case af ~ ac => af.userName === ac.userName }
|
||||||
|
.map { case _ ~ ac => ac }
|
||||||
|
.firstOption
|
||||||
|
|
||||||
|
def createAccountFederation(issuer: String, subject: String, userName: String)(implicit s: Session): Unit =
|
||||||
|
AccountFederations insert AccountFederation(issuer, subject, userName)
|
||||||
|
}
|
||||||
177
src/main/scala/gitbucket/core/service/OpenIDConnectService.scala
Normal file
177
src/main/scala/gitbucket/core/service/OpenIDConnectService.scala
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package gitbucket.core.service
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JOSEException
|
||||||
|
import com.nimbusds.jose.proc.BadJOSEException
|
||||||
|
import com.nimbusds.jose.util.DefaultResourceRetriever
|
||||||
|
import com.nimbusds.oauth2.sdk._
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic
|
||||||
|
import com.nimbusds.oauth2.sdk.id.{ClientID, Issuer, State}
|
||||||
|
import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet
|
||||||
|
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
|
||||||
|
import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator
|
||||||
|
import com.nimbusds.openid.connect.sdk.{AuthenticationErrorResponse, _}
|
||||||
|
import gitbucket.core.model.Account
|
||||||
|
import gitbucket.core.model.Profile.profile.blockingApi._
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
|
import scala.collection.JavaConverters.mapAsJavaMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service class for the OpenID Connect authentication.
|
||||||
|
*/
|
||||||
|
trait OpenIDConnectService extends AccountFederationService {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(classOf[OpenIDConnectService])
|
||||||
|
|
||||||
|
private val JWK_REQUEST_TIMEOUT = 5000
|
||||||
|
|
||||||
|
private val OIDC_SCOPE = new Scope(
|
||||||
|
OIDCScopeValue.OPENID,
|
||||||
|
OIDCScopeValue.EMAIL,
|
||||||
|
OIDCScopeValue.PROFILE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain the OIDC metadata from discovery and create an authentication request.
|
||||||
|
*
|
||||||
|
* @param issuer Issuer, used to construct the discovery endpoint URL, e.g. https://accounts.google.com
|
||||||
|
* @param clientID Client ID (given by the issuer)
|
||||||
|
* @param redirectURI Redirect URI
|
||||||
|
* @return Authentication request
|
||||||
|
*/
|
||||||
|
def createOIDCAuthenticationRequest(issuer: Issuer,
|
||||||
|
clientID: ClientID,
|
||||||
|
redirectURI: URI): AuthenticationRequest = {
|
||||||
|
val metadata = OIDCProviderMetadata.resolve(issuer)
|
||||||
|
new AuthenticationRequest(
|
||||||
|
metadata.getAuthorizationEndpointURI,
|
||||||
|
new ResponseType(ResponseType.Value.CODE),
|
||||||
|
OIDC_SCOPE,
|
||||||
|
clientID,
|
||||||
|
redirectURI,
|
||||||
|
new State(),
|
||||||
|
new Nonce())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proceed the OpenID Connect authentication.
|
||||||
|
*
|
||||||
|
* @param params Query parameters of the authentication response
|
||||||
|
* @param redirectURI Redirect URI
|
||||||
|
* @param state State saved in the session
|
||||||
|
* @param nonce Nonce saved in the session
|
||||||
|
* @param oidc OIDC settings
|
||||||
|
* @return ID token
|
||||||
|
*/
|
||||||
|
def authenticate(params: Map[String, String],
|
||||||
|
redirectURI: URI,
|
||||||
|
state: State,
|
||||||
|
nonce: Nonce,
|
||||||
|
oidc: SystemSettingsService.OIDC)(implicit s: Session): Option[Account] =
|
||||||
|
validateOIDCAuthenticationResponse(params, state, redirectURI) flatMap { authenticationResponse =>
|
||||||
|
obtainOIDCToken(authenticationResponse.getAuthorizationCode, nonce, redirectURI, oidc) flatMap { claims =>
|
||||||
|
Seq("email", "preferred_username", "name").map(k => Option(claims.getStringClaim(k))) match {
|
||||||
|
case Seq(Some(email), preferredUsername, name) =>
|
||||||
|
getOrCreateFederatedUser(claims.getIssuer.getValue, claims.getSubject.getValue, email, preferredUsername, name)
|
||||||
|
case _ =>
|
||||||
|
logger.info(s"OIDC ID token must have an email claim: claims=${claims.toJSONObject}")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the authentication response.
|
||||||
|
*
|
||||||
|
* @param params Query parameters of the authentication response
|
||||||
|
* @param state State saved in the session
|
||||||
|
* @param redirectURI Redirect URI
|
||||||
|
* @return Authentication response
|
||||||
|
*/
|
||||||
|
def validateOIDCAuthenticationResponse(params: Map[String, String], state: State, redirectURI: URI): Option[AuthenticationSuccessResponse] =
|
||||||
|
try {
|
||||||
|
AuthenticationResponseParser.parse(redirectURI, mapAsJavaMap(params)) match {
|
||||||
|
case response: AuthenticationSuccessResponse =>
|
||||||
|
if (response.getState == state) {
|
||||||
|
Some(response)
|
||||||
|
} else {
|
||||||
|
logger.info(s"OIDC authentication state did not match: response(${response.getState}) != session($state)")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
case response: AuthenticationErrorResponse =>
|
||||||
|
logger.info(s"OIDC authentication response has error: ${response.getErrorObject}")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
case e: ParseException =>
|
||||||
|
logger.info(s"OIDC authentication response has error: $e")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain the ID token from the OpenID Provider.
|
||||||
|
*
|
||||||
|
* @param authorizationCode Authorization code in the query string
|
||||||
|
* @param nonce Nonce
|
||||||
|
* @param redirectURI Redirect URI
|
||||||
|
* @param oidc OIDC settings
|
||||||
|
* @return Token response
|
||||||
|
*/
|
||||||
|
def obtainOIDCToken(authorizationCode: AuthorizationCode,
|
||||||
|
nonce: Nonce,
|
||||||
|
redirectURI: URI,
|
||||||
|
oidc: SystemSettingsService.OIDC): Option[IDTokenClaimsSet] = {
|
||||||
|
val metadata = OIDCProviderMetadata.resolve(oidc.issuer)
|
||||||
|
val tokenRequest = new TokenRequest(metadata.getTokenEndpointURI,
|
||||||
|
new ClientSecretBasic(oidc.clientID, oidc.clientSecret),
|
||||||
|
new AuthorizationCodeGrant(authorizationCode, redirectURI),
|
||||||
|
OIDC_SCOPE)
|
||||||
|
val httpResponse = tokenRequest.toHTTPRequest.send()
|
||||||
|
try {
|
||||||
|
OIDCTokenResponseParser.parse(httpResponse) match {
|
||||||
|
case response: OIDCTokenResponse =>
|
||||||
|
validateOIDCTokenResponse(response, metadata, nonce, oidc)
|
||||||
|
case response: TokenErrorResponse =>
|
||||||
|
logger.info(s"OIDC token response has error: ${response.getErrorObject.toJSONObject}")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
case e: ParseException =>
|
||||||
|
logger.info(s"OIDC token response has error: $e")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the token response.
|
||||||
|
*
|
||||||
|
* @param response Token response
|
||||||
|
* @param metadata OpenID Provider metadata
|
||||||
|
* @param nonce Nonce
|
||||||
|
* @return Claims
|
||||||
|
*/
|
||||||
|
def validateOIDCTokenResponse(response: OIDCTokenResponse,
|
||||||
|
metadata: OIDCProviderMetadata,
|
||||||
|
nonce: Nonce,
|
||||||
|
oidc: SystemSettingsService.OIDC): Option[IDTokenClaimsSet] =
|
||||||
|
Option(response.getOIDCTokens.getIDToken) match {
|
||||||
|
case Some(jwt) =>
|
||||||
|
val validator = oidc.jwsAlgorithm map { jwsAlgorithm =>
|
||||||
|
new IDTokenValidator(metadata.getIssuer, oidc.clientID, jwsAlgorithm, metadata.getJWKSetURI.toURL,
|
||||||
|
new DefaultResourceRetriever(JWK_REQUEST_TIMEOUT, JWK_REQUEST_TIMEOUT))
|
||||||
|
} getOrElse {
|
||||||
|
new IDTokenValidator(metadata.getIssuer, oidc.clientID)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Some(validator.validate(jwt, nonce))
|
||||||
|
} catch {
|
||||||
|
case e@(_: BadJOSEException | _: JOSEException) =>
|
||||||
|
logger.info(s"OIDC ID token has error: $e")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
case None =>
|
||||||
|
logger.info(s"OIDC token response does not have a valid ID token: ${response.toJSONObject}")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package gitbucket.core.service
|
package gitbucket.core.service
|
||||||
|
|
||||||
import gitbucket.core.util.Implicits._
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm
|
||||||
|
import com.nimbusds.oauth2.sdk.auth.Secret
|
||||||
|
import com.nimbusds.oauth2.sdk.id.{ClientID, Issuer}
|
||||||
|
import gitbucket.core.service.SystemSettingsService._
|
||||||
import gitbucket.core.util.ConfigUtil._
|
import gitbucket.core.util.ConfigUtil._
|
||||||
import gitbucket.core.util.Directory._
|
import gitbucket.core.util.Directory._
|
||||||
import gitbucket.core.util.SyntaxSugars._
|
import gitbucket.core.util.SyntaxSugars._
|
||||||
import SystemSettingsService._
|
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
|
|
||||||
trait SystemSettingsService {
|
trait SystemSettingsService {
|
||||||
|
|
||||||
@@ -54,6 +57,15 @@ trait SystemSettingsService {
|
|||||||
ldap.keystore.foreach(x => props.setProperty(LdapKeystore, x))
|
ldap.keystore.foreach(x => props.setProperty(LdapKeystore, x))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
props.setProperty(OidcAuthentication, settings.oidcAuthentication.toString)
|
||||||
|
if (settings.oidcAuthentication) {
|
||||||
|
settings.oidc.map { oidc =>
|
||||||
|
props.setProperty(OidcIssuer, oidc.issuer.getValue)
|
||||||
|
props.setProperty(OidcClientId, oidc.clientID.getValue)
|
||||||
|
props.setProperty(OidcClientSecret, oidc.clientSecret.getValue)
|
||||||
|
oidc.jwsAlgorithm.map { x => props.setProperty(OidcJwsAlgorithm, x.getName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
props.setProperty(SkinName, settings.skinName.toString)
|
props.setProperty(SkinName, settings.skinName.toString)
|
||||||
using(new java.io.FileOutputStream(GitBucketConf)){ out =>
|
using(new java.io.FileOutputStream(GitBucketConf)){ out =>
|
||||||
props.store(out, null)
|
props.store(out, null)
|
||||||
@@ -113,6 +125,17 @@ trait SystemSettingsService {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
getValue(props, OidcAuthentication, false),
|
||||||
|
if (getValue(props, OidcAuthentication, false)) {
|
||||||
|
Some(OIDC(
|
||||||
|
getValue(props, OidcIssuer, ""),
|
||||||
|
getValue(props, OidcClientId, ""),
|
||||||
|
getValue(props, OidcClientSecret, ""),
|
||||||
|
getOptionValue(props, OidcJwsAlgorithm, None)
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
getValue(props, SkinName, "skin-blue")
|
getValue(props, SkinName, "skin-blue")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,6 +162,8 @@ object SystemSettingsService {
|
|||||||
smtp: Option[Smtp],
|
smtp: Option[Smtp],
|
||||||
ldapAuthentication: Boolean,
|
ldapAuthentication: Boolean,
|
||||||
ldap: Option[Ldap],
|
ldap: Option[Ldap],
|
||||||
|
oidcAuthentication: Boolean,
|
||||||
|
oidc: Option[OIDC],
|
||||||
skinName: String){
|
skinName: String){
|
||||||
|
|
||||||
def baseUrl(request: HttpServletRequest): String = baseUrl.fold {
|
def baseUrl(request: HttpServletRequest): String = baseUrl.fold {
|
||||||
@@ -166,6 +191,16 @@ object SystemSettingsService {
|
|||||||
ssl: Option[Boolean],
|
ssl: Option[Boolean],
|
||||||
keystore: Option[String])
|
keystore: Option[String])
|
||||||
|
|
||||||
|
case class OIDC(
|
||||||
|
issuer: Issuer,
|
||||||
|
clientID: ClientID,
|
||||||
|
clientSecret: Secret,
|
||||||
|
jwsAlgorithm: Option[JWSAlgorithm])
|
||||||
|
object OIDC {
|
||||||
|
def apply(issuer: String, clientID: String, clientSecret: String, jwsAlgorithm: Option[String]): OIDC =
|
||||||
|
new OIDC(new Issuer(issuer), new ClientID(clientID), new Secret(clientSecret), jwsAlgorithm.map(JWSAlgorithm.parse))
|
||||||
|
}
|
||||||
|
|
||||||
case class Smtp(
|
case class Smtp(
|
||||||
host: String,
|
host: String,
|
||||||
port: Option[Int],
|
port: Option[Int],
|
||||||
@@ -221,6 +256,11 @@ object SystemSettingsService {
|
|||||||
private val LdapTls = "ldap.tls"
|
private val LdapTls = "ldap.tls"
|
||||||
private val LdapSsl = "ldap.ssl"
|
private val LdapSsl = "ldap.ssl"
|
||||||
private val LdapKeystore = "ldap.keystore"
|
private val LdapKeystore = "ldap.keystore"
|
||||||
|
private val OidcAuthentication = "oidc_authentication"
|
||||||
|
private val OidcIssuer = "oidc.issuer"
|
||||||
|
private val OidcClientId = "oidc.client_id"
|
||||||
|
private val OidcClientSecret = "oidc.client_secret"
|
||||||
|
private val OidcJwsAlgorithm = "oidc.jws_algorithm"
|
||||||
private val SkinName = "skinName"
|
private val SkinName = "skinName"
|
||||||
|
|
||||||
private def getValue[A: ClassTag](props: java.util.Properties, key: String, default: A): A = {
|
private def getValue[A: ClassTag](props: java.util.Properties, key: String, default: A): A = {
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ object Keys {
|
|||||||
*/
|
*/
|
||||||
val DashboardPulls = "dashboard/pulls"
|
val DashboardPulls = "dashboard/pulls"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session key for the OpenID Connect authentication.
|
||||||
|
*/
|
||||||
|
val OidcState = "oidc/state"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session key for the OpenID Connect authentication.
|
||||||
|
*/
|
||||||
|
val OidcNonce = "oidc/nonce"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session key for the redirect back to after SSO.
|
||||||
|
*/
|
||||||
|
val OidcRedirectBackURI = "oidc/redirectBackURI"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate session key for the issue search condition.
|
* Generate session key for the issue search condition.
|
||||||
*/
|
*/
|
||||||
|
|||||||
15
src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala
Normal file
15
src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package gitbucket.core.util
|
||||||
|
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm.Family
|
||||||
|
|
||||||
|
import scala.collection.JavaConverters.asScalaSet
|
||||||
|
|
||||||
|
object OpenIDConnectUtil {
|
||||||
|
val JWS_ALGORITHMS: Map[String, Set[JWSAlgorithm]] = Seq(
|
||||||
|
"HMAC" -> Family.HMAC_SHA,
|
||||||
|
"RSA" -> Family.RSA,
|
||||||
|
"ECDSA" -> Family.EC,
|
||||||
|
"EdDSA" -> Family.ED
|
||||||
|
).toMap.map { case (name, family) => (name, asScalaSet(family).toSet) }
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
@(info: Option[Any])(implicit context: gitbucket.core.controller.Context)
|
@(info: Option[Any])(implicit context: gitbucket.core.controller.Context)
|
||||||
@import gitbucket.core.util.DatabaseConfig
|
@import gitbucket.core.util.{DatabaseConfig, OpenIDConnectUtil}
|
||||||
@import gitbucket.core.view.helpers
|
|
||||||
@gitbucket.core.html.main("System settings"){
|
@gitbucket.core.html.main("System settings"){
|
||||||
@gitbucket.core.admin.html.menu("system"){
|
@gitbucket.core.admin.html.menu("system"){
|
||||||
@gitbucket.core.helper.html.information(info)
|
@gitbucket.core.helper.html.information(info)
|
||||||
@@ -287,6 +286,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<fieldset>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="oidcAuthentication" name="oidcAuthentication"@if(context.settings.oidc){ checked} />
|
||||||
|
OpenID Connect
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<div class="oidc">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label col-md-2" for="oidcIssuer">Issuer</label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input type="text" id="oidcIssuer" name="oidc.issuer" class="form-control" value="@context.settings.oidc.map(_.issuer.getValue)"/>
|
||||||
|
<span id="error-oidc_issuer" class="error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label col-md-2" for="oidcClientID">Client ID</label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input type="text" id="oidcClientID" name="oidc.clientID" class="form-control" value="@context.settings.oidc.map(_.clientID.getValue)"/>
|
||||||
|
<span id="error-oidc_clientID" class="error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label col-md-2" for="oidcClientID">Client secret</label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<input type="password" id="oidcClientSecret" name="oidc.clientSecret" class="form-control" value="@context.settings.oidc.map(_.clientSecret.getValue)"/>
|
||||||
|
<span id="error-oidc_clientSecret" class="error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="control-label col-md-2" for="oidcJwsAlgorithm">Expected signature</label>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<select id="oidcJwsAlgorithm" name="oidc.jwsAlgorithm" class="form-control">
|
||||||
|
<option value="" @if(context.settings.oidc.flatMap(_.jwsAlgorithm) == None){selected}>
|
||||||
|
No signature
|
||||||
|
</option>
|
||||||
|
@OpenIDConnectUtil.JWS_ALGORITHMS.map { case (family, algorithms) =>
|
||||||
|
<optgroup label="@family">
|
||||||
|
@algorithms.map { algorithm =>
|
||||||
|
<option value="@algorithm.getName" @if(context.settings.oidc.flatMap(_.jwsAlgorithm) == Some(algorithm)){selected}>
|
||||||
|
@algorithm.getName
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<span class="muted">Choose the expected signature algorithm of the token response. Most IdP provides RS256 or HS256.</span>
|
||||||
|
<span id="error-oidc_jwsAlgorithm" class="error"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!--====================================================================-->
|
<!--====================================================================-->
|
||||||
<!-- Notification email -->
|
<!-- Notification email -->
|
||||||
<!--====================================================================-->
|
<!--====================================================================-->
|
||||||
@@ -456,5 +505,9 @@ $(function(){
|
|||||||
$('#ldapAuthentication').change(function(){
|
$('#ldapAuthentication').change(function(){
|
||||||
$('.ldap input').prop('disabled', !$(this).prop('checked'));
|
$('.ldap input').prop('disabled', !$(this).prop('checked'));
|
||||||
}).change();
|
}).change();
|
||||||
|
|
||||||
|
$('#oidcAuthentication').change(function(){
|
||||||
|
$('.oidc input, .oidc select').prop('disabled', !$(this).prop('checked'));
|
||||||
|
}).change();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading strong">Sign in</div>
|
<div class="panel-heading strong">Sign in</div>
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
|
@if(context.settings.oidcAuthentication){
|
||||||
|
<li class="list-group-item">
|
||||||
|
<form action="@context.path/signin/oidc" method="POST">
|
||||||
|
<input type="hidden" name="hash"/>
|
||||||
|
<input type="submit" class="btn btn-success" value="Sign in with OpenID Connect"
|
||||||
|
onClick="this.form.hash.value = window.location.hash;"/>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<form action="@context.path/signin" method="POST" validate="true">
|
<form action="@context.path/signin" method="POST" validate="true">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -2,17 +2,16 @@ package gitbucket.core.view
|
|||||||
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
import gitbucket.core.model.Account
|
|
||||||
import gitbucket.core.service.{RequestCache, SystemSettingsService}
|
|
||||||
import gitbucket.core.controller.Context
|
|
||||||
import SystemSettingsService.SystemSettings
|
|
||||||
import javax.servlet.http.{HttpServletRequest, HttpSession}
|
import javax.servlet.http.{HttpServletRequest, HttpSession}
|
||||||
|
|
||||||
import play.twirl.api.Html
|
import gitbucket.core.controller.Context
|
||||||
|
import gitbucket.core.model.Account
|
||||||
|
import gitbucket.core.service.RequestCache
|
||||||
|
import gitbucket.core.service.SystemSettingsService.SystemSettings
|
||||||
|
import org.mockito.Mockito._
|
||||||
import org.scalatest.FunSpec
|
import org.scalatest.FunSpec
|
||||||
import org.scalatest.mockito.MockitoSugar
|
import org.scalatest.mockito.MockitoSugar
|
||||||
import org.mockito.Mockito._
|
import play.twirl.api.Html
|
||||||
|
|
||||||
|
|
||||||
class AvatarImageProviderSpec extends FunSpec with MockitoSugar {
|
class AvatarImageProviderSpec extends FunSpec with MockitoSugar {
|
||||||
@@ -119,6 +118,8 @@ class AvatarImageProviderSpec extends FunSpec with MockitoSugar {
|
|||||||
smtp = None,
|
smtp = None,
|
||||||
ldapAuthentication = false,
|
ldapAuthentication = false,
|
||||||
ldap = None,
|
ldap = None,
|
||||||
|
oidcAuthentication = false,
|
||||||
|
oidc = None,
|
||||||
skinName = "skin-blue"
|
skinName = "skin-blue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user