diff --git a/build.sbt b/build.sbt index 01ebd14..9498853 100644 --- a/build.sbt +++ b/build.sbt @@ -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 @@ "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 @@ 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" diff --git a/src/main/resources/update/gitbucket-core_4.21.xml b/src/main/resources/update/gitbucket-core_4.21.xml index 8cca0d1..c97c0e4 100644 --- a/src/main/resources/update/gitbucket-core_4.21.xml +++ b/src/main/resources/update/gitbucket-core_4.21.xml @@ -28,4 +28,12 @@ + + + + + + + + diff --git a/src/main/scala/gitbucket/core/controller/IndexController.scala b/src/main/scala/gitbucket/core/controller/IndexController.scala index ae155e9..484dc8f 100644 --- a/src/main/scala/gitbucket/core/controller/IndexController.scala +++ b/src/main/scala/gitbucket/core/controller/IndexController.scala @@ -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 @@ 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 @@ } /** - * 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 @@ 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) } } diff --git a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala index 65cdd2d..adc00e3 100644 --- a/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala +++ b/src/main/scala/gitbucket/core/controller/SystemSettingsController.scala @@ -2,23 +2,23 @@ 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 @@ "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( diff --git a/src/main/scala/gitbucket/core/model/AccountFederation.scala b/src/main/scala/gitbucket/core/model/AccountFederation.scala new file mode 100644 index 0000000..35a6b4b --- /dev/null +++ b/src/main/scala/gitbucket/core/model/AccountFederation.scala @@ -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) diff --git a/src/main/scala/gitbucket/core/model/Profile.scala b/src/main/scala/gitbucket/core/model/Profile.scala index a70af57..7cace80 100644 --- a/src/main/scala/gitbucket/core/model/Profile.scala +++ b/src/main/scala/gitbucket/core/model/Profile.scala @@ -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 @@ with RepositoryWebHookEventComponent with AccountWebHookComponent with AccountWebHookEventComponent + with AccountFederationComponent with ProtectedBranchComponent with DeployKeyComponent with ReleaseComponent diff --git a/src/main/scala/gitbucket/core/service/AccountFederationService.scala b/src/main/scala/gitbucket/core/service/AccountFederationService.scala new file mode 100644 index 0000000..932f115 --- /dev/null +++ b/src/main/scala/gitbucket/core/service/AccountFederationService.scala @@ -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) +} diff --git a/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala b/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala new file mode 100644 index 0000000..73adfff --- /dev/null +++ b/src/main/scala/gitbucket/core/service/OpenIDConnectService.scala @@ -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 + } +} diff --git a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala index bb50df4..2ad682b 100644 --- a/src/main/scala/gitbucket/core/service/SystemSettingsService.scala +++ b/src/main/scala/gitbucket/core/service/SystemSettingsService.scala @@ -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 @@ 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 @@ } 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 @@ 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 @@ 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 @@ 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 = { diff --git a/src/main/scala/gitbucket/core/util/Keys.scala b/src/main/scala/gitbucket/core/util/Keys.scala index 3581879..ee83e52 100644 --- a/src/main/scala/gitbucket/core/util/Keys.scala +++ b/src/main/scala/gitbucket/core/util/Keys.scala @@ -26,6 +26,21 @@ 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. */ def Issues(owner: String, name: String) = s"${owner}/${name}/issues" diff --git a/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala b/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala new file mode 100644 index 0000000..7fd0cc1 --- /dev/null +++ b/src/main/scala/gitbucket/core/util/OpenIDConnectUtil.scala @@ -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) } +} diff --git a/src/main/twirl/gitbucket/core/admin/system.scala.html b/src/main/twirl/gitbucket/core/admin/system.scala.html index b9017b7..d03660f 100644 --- a/src/main/twirl/gitbucket/core/admin/system.scala.html +++ b/src/main/twirl/gitbucket/core/admin/system.scala.html @@ -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 @@ +
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + Choose the expected signature algorithm of the token response. Most IdP provides RS256 or HS256. + +
+
+
@@ -456,5 +505,9 @@ $('#ldapAuthentication').change(function(){ $('.ldap input').prop('disabled', !$(this).prop('checked')); }).change(); + + $('#oidcAuthentication').change(function(){ + $('.oidc input, .oidc select').prop('disabled', !$(this).prop('checked')); + }).change(); }); diff --git a/src/main/twirl/gitbucket/core/signinform.scala.html b/src/main/twirl/gitbucket/core/signinform.scala.html index 09c27d2..686e30d 100644 --- a/src/main/twirl/gitbucket/core/signinform.scala.html +++ b/src/main/twirl/gitbucket/core/signinform.scala.html @@ -4,6 +4,15 @@
Sign in
    + @if(context.settings.oidcAuthentication){ +
  • +
    + + +
    +
  • + }
  • diff --git a/src/test/scala/gitbucket/core/view/AvatarImageProviderSpec.scala b/src/test/scala/gitbucket/core/view/AvatarImageProviderSpec.scala index ba24069..2e08135 100644 --- a/src/test/scala/gitbucket/core/view/AvatarImageProviderSpec.scala +++ b/src/test/scala/gitbucket/core/view/AvatarImageProviderSpec.scala @@ -2,17 +2,16 @@ 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 @@ smtp = None, ldapAuthentication = false, ldap = None, + oidcAuthentication = false, + oidc = None, skinName = "skin-blue" )