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._
|
||||
|
||||
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"),
|
||||
"net.coobird" % "thumbnailator" % "0.4.8",
|
||||
"com.github.zafarkhaja" % "java-semver" % "0.9.0",
|
||||
"com.nimbusds" % "oauth2-oidc-sdk" % "5.45",
|
||||
"org.eclipse.jetty" % "jetty-webapp" % JettyVersion % "provided",
|
||||
"javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
|
||||
"junit" % "junit" % "4.12" % "test",
|
||||
@@ -127,8 +128,8 @@ libraryDependencies ++= Seq(
|
||||
|
||||
val executableKey = TaskKey[File]("executable")
|
||||
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 warName = Keys.name.value + ".war"
|
||||
|
||||
@@ -28,4 +28,12 @@
|
||||
</createTable>
|
||||
<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"/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
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.model.Account
|
||||
import gitbucket.core.service._
|
||||
import gitbucket.core.util.Implicits._
|
||||
import gitbucket.core.util.SyntaxSugars._
|
||||
import gitbucket.core.util.{Keys, LDAPUtil, ReferrerAuthenticator, UsersAuthenticator}
|
||||
import org.scalatra.forms._
|
||||
import org.scalatra.Ok
|
||||
import org.scalatra.forms._
|
||||
|
||||
|
||||
class IndexController extends IndexControllerBase
|
||||
with RepositoryService with ActivityService with AccountService with RepositorySearchService with IssuesService
|
||||
with UsersAuthenticator with ReferrerAuthenticator
|
||||
with RepositoryService
|
||||
with ActivityService
|
||||
with AccountService
|
||||
with RepositorySearchService
|
||||
with IssuesService
|
||||
with UsersAuthenticator
|
||||
with ReferrerAuthenticator
|
||||
with OpenIDConnectService
|
||||
|
||||
|
||||
trait IndexControllerBase extends ControllerBase {
|
||||
self: RepositoryService with ActivityService with AccountService with RepositorySearchService
|
||||
with UsersAuthenticator with ReferrerAuthenticator =>
|
||||
self: RepositoryService
|
||||
with ActivityService
|
||||
with AccountService
|
||||
with RepositorySearchService
|
||||
with UsersAuthenticator
|
||||
with ReferrerAuthenticator
|
||||
with OpenIDConnectService =>
|
||||
|
||||
case class SignInForm(userName: String, password: String, hash: Option[String])
|
||||
|
||||
@@ -55,13 +70,61 @@ trait IndexControllerBase extends ControllerBase {
|
||||
|
||||
post("/signin", signinForm){ form =>
|
||||
authenticate(context.settings, form.userName, form.password) match {
|
||||
case Some(account) => signin(account, form.hash)
|
||||
case None => {
|
||||
case Some(account) =>
|
||||
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 += "password" -> form.password
|
||||
flash += "error" -> "Sorry, your Username and/or Password is incorrect. Please try again."
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +148,9 @@ trait IndexControllerBase extends ControllerBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set account information into HttpSession and redirect.
|
||||
*/
|
||||
private def signin(account: Account, hash: Option[String]) = {
|
||||
* Set account information into HttpSession and redirect.
|
||||
*/
|
||||
private def signin(account: Account, redirectUrl: String = "/") = {
|
||||
session.setAttribute(Keys.Session.LoginAccount, account)
|
||||
updateLastLoginDate(account.userName)
|
||||
|
||||
@@ -95,14 +158,10 @@ trait IndexControllerBase extends ControllerBase {
|
||||
redirect("/" + account.userName + "/_edit")
|
||||
}
|
||||
|
||||
flash.get(Keys.Flash.Redirect).asInstanceOf[Option[String]].map { redirectUrl =>
|
||||
if(redirectUrl.stripSuffix("/") == request.getContextPath){
|
||||
redirect("/")
|
||||
} else {
|
||||
redirect(redirectUrl + hash.getOrElse(""))
|
||||
}
|
||||
}.getOrElse {
|
||||
if (redirectUrl.stripSuffix("/") == request.getContextPath) {
|
||||
redirect("/")
|
||||
} else {
|
||||
redirect(redirectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,23 @@ package gitbucket.core.controller
|
||||
|
||||
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 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.scalatra._
|
||||
import org.scalatra.forms._
|
||||
import org.scalatra.i18n.Messages
|
||||
|
||||
import scala.collection.JavaConverters._
|
||||
import scala.collection.mutable.ListBuffer
|
||||
@@ -70,6 +70,13 @@ trait SystemSettingsControllerBase extends AccountManagementControllerBase {
|
||||
"ssl" -> trim(label("Enable SSL", optional(boolean()))),
|
||||
"keystore" -> trim(label("Keystore", optional(text())))
|
||||
)(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)))
|
||||
)(SystemSettings.apply).verifying { settings =>
|
||||
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
|
||||
|
||||
import gitbucket.core.util.DatabaseConfig
|
||||
import com.github.takezoe.slick.blocking.BlockingJdbcProfile
|
||||
import gitbucket.core.util.DatabaseConfig
|
||||
|
||||
trait Profile {
|
||||
val profile: BlockingJdbcProfile
|
||||
@@ -61,6 +61,7 @@ trait CoreProfile extends ProfileProvider with Profile
|
||||
with RepositoryWebHookEventComponent
|
||||
with AccountWebHookComponent
|
||||
with AccountWebHookEventComponent
|
||||
with AccountFederationComponent
|
||||
with ProtectedBranchComponent
|
||||
with DeployKeyComponent
|
||||
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
|
||||
|
||||
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.Directory._
|
||||
import gitbucket.core.util.SyntaxSugars._
|
||||
import SystemSettingsService._
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
|
||||
trait SystemSettingsService {
|
||||
|
||||
@@ -54,6 +57,15 @@ trait SystemSettingsService {
|
||||
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)
|
||||
using(new java.io.FileOutputStream(GitBucketConf)){ out =>
|
||||
props.store(out, null)
|
||||
@@ -113,6 +125,17 @@ trait SystemSettingsService {
|
||||
} else {
|
||||
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")
|
||||
)
|
||||
}
|
||||
@@ -139,6 +162,8 @@ object SystemSettingsService {
|
||||
smtp: Option[Smtp],
|
||||
ldapAuthentication: Boolean,
|
||||
ldap: Option[Ldap],
|
||||
oidcAuthentication: Boolean,
|
||||
oidc: Option[OIDC],
|
||||
skinName: String){
|
||||
|
||||
def baseUrl(request: HttpServletRequest): String = baseUrl.fold {
|
||||
@@ -166,6 +191,16 @@ object SystemSettingsService {
|
||||
ssl: Option[Boolean],
|
||||
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(
|
||||
host: String,
|
||||
port: Option[Int],
|
||||
@@ -221,6 +256,11 @@ object SystemSettingsService {
|
||||
private val LdapTls = "ldap.tls"
|
||||
private val LdapSsl = "ldap.ssl"
|
||||
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 def getValue[A: ClassTag](props: java.util.Properties, key: String, default: A): A = {
|
||||
|
||||
@@ -25,6 +25,21 @@ object Keys {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
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)
|
||||
@import gitbucket.core.util.DatabaseConfig
|
||||
@import gitbucket.core.view.helpers
|
||||
@import gitbucket.core.util.{DatabaseConfig, OpenIDConnectUtil}
|
||||
@gitbucket.core.html.main("System settings"){
|
||||
@gitbucket.core.admin.html.menu("system"){
|
||||
@gitbucket.core.helper.html.information(info)
|
||||
@@ -287,6 +286,56 @@
|
||||
</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 -->
|
||||
<!--====================================================================-->
|
||||
@@ -456,5 +505,9 @@ $(function(){
|
||||
$('#ldapAuthentication').change(function(){
|
||||
$('.ldap input').prop('disabled', !$(this).prop('checked'));
|
||||
}).change();
|
||||
|
||||
$('#oidcAuthentication').change(function(){
|
||||
$('.oidc input, .oidc select').prop('disabled', !$(this).prop('checked'));
|
||||
}).change();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading strong">Sign in</div>
|
||||
<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">
|
||||
<form action="@context.path/signin" method="POST" validate="true">
|
||||
<div class="form-group">
|
||||
|
||||
@@ -2,17 +2,16 @@ package gitbucket.core.view
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
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 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.mockito.MockitoSugar
|
||||
import org.mockito.Mockito._
|
||||
import play.twirl.api.Html
|
||||
|
||||
|
||||
class AvatarImageProviderSpec extends FunSpec with MockitoSugar {
|
||||
@@ -119,6 +118,8 @@ class AvatarImageProviderSpec extends FunSpec with MockitoSugar {
|
||||
smtp = None,
|
||||
ldapAuthentication = false,
|
||||
ldap = None,
|
||||
oidcAuthentication = false,
|
||||
oidc = None,
|
||||
skinName = "skin-blue"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user