Compare commits

..

12 Commits

31 changed files with 442 additions and 36 deletions

View File

@@ -47,6 +47,7 @@ p, *, *, GET, /api/get-app-login, *, *
p, *, *, POST, /api/logout, *, *
p, *, *, GET, /api/logout, *, *
p, *, *, POST, /api/callback, *, *
p, *, *, POST, /api/device-auth, *, *
p, *, *, GET, /api/get-account, *, *
p, *, *, GET, /api/userinfo, *, *
p, *, *, GET, /api/user, *, *

View File

@@ -31,7 +31,7 @@ radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"
frontendBaseDir = "../cc_0"

View File

@@ -115,7 +115,7 @@ func TestGetConfigLogs(t *testing.T) {
description string
expected string
}{
{"Default log config", `{"filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}`},
{"Default log config", `{"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}`},
}
err := beego.LoadAppConfig("ini", "app.conf")

View File

@@ -32,6 +32,7 @@ const (
ResponseTypeIdToken = "id_token"
ResponseTypeSaml = "saml"
ResponseTypeCas = "cas"
ResponseTypeDevice = "device"
)
type Response struct {

View File

@@ -25,6 +25,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/casdoor/casdoor/captcha"
"github.com/casdoor/casdoor/conf"
@@ -146,7 +147,7 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
c.ResponseError(c.T("auth:Challenge method should be S256"))
return
}
code, err := object.GetOAuthCode(userId, clientId, responseType, redirectUri, scope, state, nonce, codeChallenge, c.Ctx.Request.Host, c.GetAcceptLanguage())
code, err := object.GetOAuthCode(userId, clientId, form.Provider, responseType, redirectUri, scope, state, nonce, codeChallenge, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
return
@@ -169,6 +170,32 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
resp.Data2 = user.NeedUpdatePassword
}
} else if form.Type == ResponseTypeDevice {
authCache, ok := object.DeviceAuthMap.LoadAndDelete(form.UserCode)
if !ok {
c.ResponseError(c.T("auth:UserCode Expired"))
return
}
authCacheCast := authCache.(object.DeviceAuthCache)
if authCacheCast.RequestAt.Add(time.Second * 120).Before(time.Now()) {
c.ResponseError(c.T("auth:UserCode Expired"))
return
}
deviceAuthCacheDeviceCode, ok := object.DeviceAuthMap.Load(authCacheCast.UserName)
if !ok {
c.ResponseError(c.T("auth:DeviceCode Invalid"))
return
}
deviceAuthCacheDeviceCodeCast := deviceAuthCacheDeviceCode.(object.DeviceAuthCache)
deviceAuthCacheDeviceCodeCast.UserName = user.Name
deviceAuthCacheDeviceCodeCast.UserSignIn = true
object.DeviceAuthMap.Store(authCacheCast.UserName, deviceAuthCacheDeviceCodeCast)
resp = &Response{Status: "ok", Msg: "", Data: userId, Data2: user.NeedUpdatePassword}
} else if form.Type == ResponseTypeSaml { // saml flow
res, redirectUrl, method, err := object.GetSamlResponse(application, user, form.SamlRequest, c.Ctx.Request.Host)
if err != nil {
@@ -242,6 +269,7 @@ func (c *ApiController) GetApplicationLogin() {
state := c.Input().Get("state")
id := c.Input().Get("id")
loginType := c.Input().Get("type")
userCode := c.Input().Get("userCode")
var application *object.Application
var msg string
@@ -268,6 +296,19 @@ func (c *ApiController) GetApplicationLogin() {
c.ResponseError(err.Error())
return
}
} else if loginType == "device" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(userCode)
if !ok {
c.ResponseError(c.T("auth:UserCode Invalid"))
return
}
deviceAuthCacheCast := deviceAuthCache.(object.DeviceAuthCache)
application, err = object.GetApplication(deviceAuthCacheCast.ApplicationId)
if err != nil {
c.ResponseError(err.Error())
return
}
}
clientIp := util.GetClientIpFromRequest(c.Ctx.Request)
@@ -1215,3 +1256,75 @@ func (c *ApiController) Callback() {
frontendCallbackUrl := fmt.Sprintf("/callback?code=%s&state=%s", code, state)
c.Ctx.Redirect(http.StatusFound, frontendCallbackUrl)
}
// DeviceAuth
// @Title DeviceAuth
// @Tag Device Authorization Endpoint
// @Description Endpoint for the device authorization flow
// @router /device-auth [post]
// @Success 200 {object} object.DeviceAuthResponse The Response object
func (c *ApiController) DeviceAuth() {
clientId := c.Input().Get("client_id")
scope := c.Input().Get("scope")
application, err := object.GetApplicationByClientId(clientId)
if err != nil {
c.Data["json"] = object.TokenError{
Error: err.Error(),
ErrorDescription: err.Error(),
}
c.ServeJSON()
return
}
if application == nil {
c.Data["json"] = object.TokenError{
Error: c.T("token:Invalid client_id"),
ErrorDescription: c.T("token:Invalid client_id"),
}
c.ServeJSON()
return
}
deviceCode := util.GenerateId()
userCode := util.GetRandomName()
generateTime := 0
for {
if generateTime > 5 {
c.Data["json"] = object.TokenError{
Error: "userCode gen",
ErrorDescription: c.T("token:Invalid client_id"),
}
c.ServeJSON()
return
}
_, ok := object.DeviceAuthMap.Load(userCode)
if !ok {
break
}
generateTime++
}
deviceAuthCache := object.DeviceAuthCache{
UserSignIn: false,
UserName: "",
Scope: scope,
ApplicationId: application.GetId(),
RequestAt: time.Now(),
}
userAuthCache := object.DeviceAuthCache{
UserSignIn: false,
UserName: deviceCode,
Scope: scope,
ApplicationId: application.GetId(),
RequestAt: time.Now(),
}
object.DeviceAuthMap.Store(deviceCode, deviceAuthCache)
object.DeviceAuthMap.Store(userCode, userAuthCache)
c.Data["json"] = object.GetDeviceAuthResponse(deviceCode, userCode, c.Ctx.Request.Host)
c.ServeJSON()
}

View File

@@ -16,6 +16,7 @@ package controllers
import (
"encoding/json"
"time"
"github.com/beego/beego/utils/pagination"
"github.com/casdoor/casdoor/object"
@@ -170,12 +171,17 @@ func (c *ApiController) GetOAuthToken() {
tag := c.Input().Get("tag")
avatar := c.Input().Get("avatar")
refreshToken := c.Input().Get("refresh_token")
deviceCode := c.Input().Get("device_code")
if clientId == "" && clientSecret == "" {
clientId, clientSecret, _ = c.Ctx.Request.BasicAuth()
}
if len(c.Ctx.Input.RequestBody) != 0 {
if grantType == "urn:ietf:params:oauth:grant-type:device_code" {
clientId, clientSecret, _ = c.Ctx.Request.BasicAuth()
}
if len(c.Ctx.Input.RequestBody) != 0 && grantType != "urn:ietf:params:oauth:grant-type:device_code" {
// If clientId is empty, try to read data from RequestBody
var tokenRequest TokenRequest
err := json.Unmarshal(c.Ctx.Input.RequestBody, &tokenRequest)
@@ -219,6 +225,40 @@ func (c *ApiController) GetOAuthToken() {
}
}
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
if !ok {
c.Data["json"] = object.TokenError{
Error: "expired_token",
ErrorDescription: "token is expired",
}
c.ServeJSON()
return
}
deviceAuthCacheCast := deviceAuthCache.(object.DeviceAuthCache)
if !deviceAuthCacheCast.UserSignIn {
c.Data["json"] = object.TokenError{
Error: "authorization_pending",
ErrorDescription: "authorization pending",
}
c.ServeJSON()
return
}
if deviceAuthCacheCast.RequestAt.Add(time.Second * 120).Before(time.Now()) {
c.Data["json"] = object.TokenError{
Error: "expired_token",
ErrorDescription: "token is expired",
}
c.ServeJSON()
return
}
object.DeviceAuthMap.Delete(deviceCode)
username = deviceAuthCacheCast.UserName
}
host := c.Ctx.Request.Host
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage())
if err != nil {

View File

@@ -34,6 +34,8 @@ func GetCredManager(passwordType string) CredManager {
return NewPbkdf2SaltCredManager()
} else if passwordType == "argon2id" {
return NewArgon2idCredManager()
} else if passwordType == "pbkdf2-django" {
return NewPbkdf2DjangoCredManager()
}
return nil
}

71
cred/pbkdf2_django.go Normal file
View File

@@ -0,0 +1,71 @@
// Copyright 2025 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package cred
import (
"crypto/sha256"
"encoding/base64"
"strconv"
"strings"
"golang.org/x/crypto/pbkdf2"
)
// password type: pbkdf2-django
type Pbkdf2DjangoCredManager struct{}
func NewPbkdf2DjangoCredManager() *Pbkdf2DjangoCredManager {
cm := &Pbkdf2DjangoCredManager{}
return cm
}
func (m *Pbkdf2DjangoCredManager) GetHashedPassword(password string, userSalt string, organizationSalt string) string {
iterations := 260000
salt := userSalt
if salt == "" {
salt = organizationSalt
}
saltBytes := []byte(salt)
passwordBytes := []byte(password)
computedHash := pbkdf2.Key(passwordBytes, saltBytes, iterations, sha256.Size, sha256.New)
hashBase64 := base64.StdEncoding.EncodeToString(computedHash)
return "pbkdf2_sha256$" + strconv.Itoa(iterations) + "$" + salt + "$" + hashBase64
}
func (m *Pbkdf2DjangoCredManager) IsPasswordCorrect(password string, passwordHash string, userSalt string, organizationSalt string) bool {
parts := strings.Split(passwordHash, "$")
if len(parts) != 4 {
return false
}
algorithm, iterations, salt, hash := parts[0], parts[1], parts[2], parts[3]
if algorithm != "pbkdf2_sha256" {
return false
}
iter, err := strconv.Atoi(iterations)
if err != nil {
return false
}
saltBytes := []byte(salt)
passwordBytes := []byte(password)
computedHash := pbkdf2.Key(passwordBytes, saltBytes, iter, sha256.Size, sha256.New)
computedHashBase64 := base64.StdEncoding.EncodeToString(computedHash)
return computedHashBase64 == hash
}

View File

@@ -70,6 +70,7 @@ type AuthForm struct {
FaceId []float64 `json:"faceId"`
FaceIdImage []string `json:"faceIdImage"`
UserCode string `json:"userCode"`
}
func GetAuthFormFieldValue(form *AuthForm, fieldName string) (bool, string) {

4
go.mod
View File

@@ -16,7 +16,7 @@ require (
github.com/casdoor/go-sms-sender v0.25.0
github.com/casdoor/gomail/v2 v2.1.0
github.com/casdoor/ldapserver v1.2.0
github.com/casdoor/notify v1.0.0
github.com/casdoor/notify v1.0.1
github.com/casdoor/oss v1.8.0
github.com/casdoor/xorm-adapter/v3 v3.1.0
github.com/casvisor/casvisor-go-sdk v1.4.0
@@ -101,7 +101,7 @@ require (
github.com/aliyun/aliyun-oss-go-sdk v2.2.2+incompatible // indirect
github.com/aliyun/credentials-go v1.3.10 // indirect
github.com/apistd/uni-go-sdk v0.0.2 // indirect
github.com/atc0005/go-teams-notify/v2 v2.6.1 // indirect
github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.156 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blinkbean/dingtalk v0.0.0-20210905093040-7d935c0f7e19 // indirect

8
go.sum
View File

@@ -188,8 +188,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/atc0005/go-teams-notify/v2 v2.6.1 h1:t22ybzQuaQs4UJe4ceF5VYGsPhs6ir3nZOId/FBy6Go=
github.com/atc0005/go-teams-notify/v2 v2.6.1/go.mod h1:xo6GejLDHn3tWBA181F8LrllIL0xC1uRsRxq7YNXaaY=
github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw=
github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.45.5 h1:bxilnhv9FngUgdPNJmOIv2bk+2sP0dpqX3e4olhWcGM=
@@ -238,8 +238,8 @@ github.com/casdoor/gomail/v2 v2.1.0 h1:ua97E3CARnF1Ik8ga/Drz9uGZfaElXJumFexiErWU
github.com/casdoor/gomail/v2 v2.1.0/go.mod h1:GFzOD9RhY0nODiiPaQiOa6DfoKtmO9aTesu5qrp26OI=
github.com/casdoor/ldapserver v1.2.0 h1:HdSYe+ULU6z9K+2BqgTrJKQRR4//ERAXB64ttOun6Ow=
github.com/casdoor/ldapserver v1.2.0/go.mod h1:VwYU2vqQ2pA8sa00PRekH71R2XmgfzMKhmp1XrrDu2s=
github.com/casdoor/notify v1.0.0 h1:oldsaaQFPrlufm/OA314z8DwFVE1Tc9Gt1z4ptRHhXw=
github.com/casdoor/notify v1.0.0/go.mod h1:wNHQu0tiDROMBIvz0j3Om3Lhd5yZ+AIfnFb8MYb8OLQ=
github.com/casdoor/notify v1.0.1 h1:p0kzI7OBlvLbL7zWeKIu31LRcEAygNZGKr5gcFfSIoE=
github.com/casdoor/notify v1.0.1/go.mod h1:RUlaFJw87FoM/nbs0iXPP0h+DxKGTaWAIFQV0oZcSQA=
github.com/casdoor/oss v1.8.0 h1:uuyKhDIp7ydOtV4lpqhAY23Ban2Ln8La8+QT36CwylM=
github.com/casdoor/oss v1.8.0/go.mod h1:uaqO7KBI2lnZcnB8rF7O6C2bN7llIbfC5Ql8ex1yR1U=
github.com/casdoor/xorm-adapter/v3 v3.1.0 h1:NodWayRtSLVSeCvL9H3Hc61k0G17KhV9IymTCNfh3kk=

19
main.go
View File

@@ -15,6 +15,7 @@
package main
import (
"encoding/json"
"fmt"
"github.com/beego/beego"
@@ -77,10 +78,26 @@ func main() {
beego.BConfig.WebConfig.Session.SessionGCMaxLifetime = 3600 * 24 * 30
// beego.BConfig.WebConfig.Session.SessionCookieSameSite = http.SameSiteNoneMode
err := logs.SetLogger(logs.AdapterFile, conf.GetConfigString("logConfig"))
var logAdapter string
logConfigMap := make(map[string]interface{})
err := json.Unmarshal([]byte(conf.GetConfigString("logConfig")), &logConfigMap)
if err != nil {
panic(err)
}
_, ok := logConfigMap["adapter"]
if !ok {
logAdapter = "file"
} else {
logAdapter = logConfigMap["adapter"].(string)
}
if logAdapter == "console" {
logs.Reset()
}
err = logs.SetLogger(logAdapter, conf.GetConfigString("logConfig"))
if err != nil {
panic(err)
}
port := beego.AppConfig.DefaultInt("httpport", 8000)
// logs.SetLevel(logs.LevelInformational)
logs.SetLogFuncCall(false)

View File

@@ -85,7 +85,7 @@ type Application struct {
EnableWebAuthn bool `json:"enableWebAuthn"`
EnableLinkWithEmail bool `json:"enableLinkWithEmail"`
OrgChoiceMode string `json:"orgChoiceMode"`
SamlReplyUrl string `xorm:"varchar(100)" json:"samlReplyUrl"`
SamlReplyUrl string `xorm:"varchar(500)" json:"samlReplyUrl"`
Providers []*ProviderItem `xorm:"mediumtext" json:"providers"`
SigninMethods []*SigninMethod `xorm:"varchar(2000)" json:"signinMethods"`
SignupItems []*SignupItem `xorm:"varchar(3000)" json:"signupItems"`

View File

@@ -63,7 +63,11 @@ func GetCertCount(owner, field, value string) (int64, error) {
func GetCerts(owner string) ([]*Cert, error) {
certs := []*Cert{}
err := ormer.Engine.Where("owner = ? or owner = ? ", "admin", owner).Desc("created_time").Find(&certs, &Cert{})
db := ormer.Engine.NewSession()
if owner != "" {
db = db.Where("owner = ? or owner = ? ", "admin", owner)
}
err := db.Desc("created_time").Find(&certs, &Cert{})
if err != nil {
return certs, err
}

View File

@@ -17,6 +17,7 @@ package object
import (
"errors"
"fmt"
"strings"
"github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util"
@@ -210,6 +211,12 @@ func DeleteGroup(group *Group) (bool, error) {
}
func checkGroupName(name string) error {
if name == "" {
return errors.New("group name can't be empty")
}
if strings.Contains(name, "/") {
return errors.New("group name can't contain \"/\"")
}
exist, err := ormer.Engine.Exist(&Organization{Owner: "admin", Name: name})
if err != nil {
return err

View File

@@ -30,6 +30,7 @@ type OidcDiscovery struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
JwksUri string `json:"jwks_uri"`
IntrospectionEndpoint string `json:"introspection_endpoint"`
ResponseTypesSupported []string `json:"response_types_supported"`
@@ -119,6 +120,7 @@ func GetOidcDiscovery(host string) OidcDiscovery {
AuthorizationEndpoint: fmt.Sprintf("%s/login/oauth/authorize", originFrontend),
TokenEndpoint: fmt.Sprintf("%s/api/login/oauth/access_token", originBackend),
UserinfoEndpoint: fmt.Sprintf("%s/api/userinfo", originBackend),
DeviceAuthorizationEndpoint: fmt.Sprintf("%s/api/device-auth", originBackend),
JwksUri: fmt.Sprintf("%s/.well-known/jwks", originBackend),
IntrospectionEndpoint: fmt.Sprintf("%s/api/login/oauth/introspect", originBackend),
ResponseTypesSupported: []string{"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"},
@@ -138,7 +140,7 @@ func GetOidcDiscovery(host string) OidcDiscovery {
func GetJsonWebKeySet() (jose.JSONWebKeySet, error) {
jwks := jose.JSONWebKeySet{}
certs, err := GetCerts("admin")
certs, err := GetCerts("")
if err != nil {
return jwks, err
}
@@ -213,3 +215,14 @@ func GetWebFinger(resource string, rels []string, host string) (WebFinger, error
return wf, nil
}
func GetDeviceAuthResponse(deviceCode string, userCode string, host string) DeviceAuthResponse {
originFrontend, _ := getOriginFromHost(host)
return DeviceAuthResponse{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationUri: fmt.Sprintf("%s/login/oauth/device/%s", originFrontend, userCode),
ExpiresIn: 120,
}
}

View File

@@ -179,7 +179,7 @@ func NewAdapterFromDb(driverName string, dataSourceName string, dbName string, d
func refineDataSourceNameForPostgres(dataSourceName string) string {
reg := regexp.MustCompile(`dbname=[^ ]+\s*`)
return reg.ReplaceAllString(dataSourceName, "")
return reg.ReplaceAllString(dataSourceName, "dbname=postgres")
}
func createDatabaseForPostgres(driverName string, dataSourceName string, dbName string) error {
@@ -190,7 +190,7 @@ func createDatabaseForPostgres(driverName string, dataSourceName string, dbName
}
defer db.Close()
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName))
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\";", dbName))
if err != nil {
if !strings.Contains(err.Error(), "already exists") {
return err

View File

@@ -263,6 +263,27 @@ func addWebhookRecord(webhook *Webhook, record *casvisorsdk.Record, statusCode i
return err
}
func filterRecordObject(object string, objectFields []string) string {
var rawObject map[string]interface{}
_ = json.Unmarshal([]byte(object), &rawObject)
if rawObject == nil {
return object
}
filteredObject := make(map[string]interface{})
for _, field := range objectFields {
fieldValue, ok := rawObject[field]
if !ok {
continue
}
filteredObject[field] = fieldValue
}
return util.StructToJson(filteredObject)
}
func SendWebhooks(record *casvisorsdk.Record) error {
webhooks, err := getWebhooksByOrganization("")
if err != nil {
@@ -271,7 +292,14 @@ func SendWebhooks(record *casvisorsdk.Record) error {
errs := []error{}
webhooks = getFilteredWebhooks(webhooks, record.Organization, record.Action)
record2 := *record
for _, webhook := range webhooks {
if len(webhook.ObjectFields) != 0 && webhook.ObjectFields[0] != "All" {
record2.Object = filterRecordObject(record.Object, webhook.ObjectFields)
}
var user *User
if webhook.IsUserExtended {
user, err = getUser(record.Organization, record.User)
@@ -287,12 +315,12 @@ func SendWebhooks(record *casvisorsdk.Record) error {
}
}
statusCode, respBody, err := sendWebhook(webhook, record, user)
statusCode, respBody, err := sendWebhook(webhook, &record2, user)
if err != nil {
errs = append(errs, err)
}
err = addWebhookRecord(webhook, record, statusCode, respBody, err)
err = addWebhookRecord(webhook, &record2, statusCode, respBody, err)
if err != nil {
errs = append(errs, err)
}

View File

@@ -31,7 +31,8 @@ type Claims struct {
Tag string `json:"tag"`
Scope string `json:"scope,omitempty"`
// the `azp` (Authorized Party) claim. Optional. See https://openid.net/specs/openid-connect-core-1_0.html#IDToken
Azp string `json:"azp,omitempty"`
Azp string `json:"azp,omitempty"`
Provider string `json:"provider,omitempty"`
jwt.RegisteredClaims
}
@@ -140,6 +141,7 @@ type ClaimsShort struct {
Nonce string `json:"nonce,omitempty"`
Scope string `json:"scope,omitempty"`
Azp string `json:"azp,omitempty"`
Provider string `json:"provider,omitempty"`
jwt.RegisteredClaims
}
@@ -159,6 +161,7 @@ type ClaimsWithoutThirdIdp struct {
Tag string `json:"tag"`
Scope string `json:"scope,omitempty"`
Azp string `json:"azp,omitempty"`
Provider string `json:"provider,omitempty"`
jwt.RegisteredClaims
}
@@ -274,6 +277,7 @@ func getShortClaims(claims Claims) ClaimsShort {
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
return res
}
@@ -287,6 +291,7 @@ func getClaimsWithoutThirdIdp(claims Claims) ClaimsWithoutThirdIdp {
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
return res
}
@@ -308,6 +313,7 @@ func getClaimsCustom(claims Claims, tokenField []string) jwt.MapClaims {
res["tag"] = claims.Tag
res["scope"] = claims.Scope
res["azp"] = claims.Azp
res["provider"] = claims.Provider
for _, field := range tokenField {
userField := userValue.FieldByName(field)
@@ -342,7 +348,7 @@ func refineUser(user *User) *User {
return user
}
func generateJwtToken(application *Application, user *User, nonce string, scope string, host string) (string, string, string, error) {
func generateJwtToken(application *Application, user *User, provider string, nonce string, scope string, host string) (string, string, string, error) {
nowTime := time.Now()
expireTime := nowTime.Add(time.Duration(application.ExpireInHours) * time.Hour)
refreshExpireTime := nowTime.Add(time.Duration(application.RefreshExpireInHours) * time.Hour)
@@ -362,9 +368,10 @@ func generateJwtToken(application *Application, user *User, nonce string, scope
TokenType: "access-token",
Nonce: nonce,
// FIXME: A workaround for custom claim by reusing `tag` in user info
Tag: user.Tag,
Scope: scope,
Azp: application.ClientId,
Tag: user.Tag,
Scope: scope,
Azp: application.ClientId,
Provider: provider,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: originBackend,
Subject: user.Id,

View File

@@ -18,6 +18,7 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"sync"
"time"
"github.com/casdoor/casdoor/i18n"
@@ -37,6 +38,8 @@ const (
EndpointError = "endpoint_error"
)
var DeviceAuthMap = sync.Map{}
type Code struct {
Message string `xorm:"varchar(100)" json:"message"`
Code string `xorm:"varchar(100)" json:"code"`
@@ -71,6 +74,22 @@ type IntrospectionResponse struct {
Jti string `json:"jti,omitempty"`
}
type DeviceAuthCache struct {
UserSignIn bool
UserName string
ApplicationId string
Scope string
RequestAt time.Time
}
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
token, err := GetTokenByAccessToken(accessToken)
if err != nil {
@@ -117,7 +136,7 @@ func CheckOAuthLogin(clientId string, responseType string, redirectUri string, s
return "", application, nil
}
func GetOAuthCode(userId string, clientId string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, host string, lang string) (*Code, error) {
func GetOAuthCode(userId string, clientId string, provider string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, host string, lang string) (*Code, error) {
user, err := GetUser(userId)
if err != nil {
return nil, err
@@ -152,7 +171,7 @@ func GetOAuthCode(userId string, clientId string, responseType string, redirectU
if err != nil {
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, nonce, scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, nonce, scope, host)
if err != nil {
return nil, err
}
@@ -222,6 +241,8 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
case "token", "id_token": // Implicit Grant
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:device_code":
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "refresh_token":
refreshToken2, err := RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
@@ -358,7 +379,7 @@ func RefreshToken(grantType string, refreshToken string, scope string, clientId
return nil, err
}
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", scope, host)
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", scope, host)
if err != nil {
return &TokenError{
Error: EndpointError,
@@ -537,7 +558,7 @@ func GetPasswordToken(application *Application, username string, password string
return nil, nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", scope, host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -583,7 +604,7 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
Type: "application",
}
accessToken, _, tokenName, err := generateJwtToken(application, nullUser, "", scope, host)
accessToken, _, tokenName, err := generateJwtToken(application, nullUser, "", "", scope, host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
@@ -647,7 +668,7 @@ func GetTokenByUser(application *Application, user *User, scope string, nonce st
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, nonce, scope, host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", nonce, scope, host)
if err != nil {
return nil, err
}
@@ -754,7 +775,7 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
return nil, nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", host)
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", host)
if err != nil {
return nil, &TokenError{
Error: EndpointError,

View File

@@ -33,6 +33,7 @@ type ClaimsStandard struct {
Scope string `json:"scope,omitempty"`
Address OIDCAddress `json:"address,omitempty"`
Azp string `json:"azp,omitempty"`
Provider string `json:"provider,omitempty"`
jwt.RegisteredClaims
}
@@ -54,6 +55,7 @@ func getStandardClaims(claims Claims) ClaimsStandard {
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
res.Phone = ""

View File

@@ -39,6 +39,7 @@ type Webhook struct {
Headers []*Header `xorm:"mediumtext" json:"headers"`
Events []string `xorm:"varchar(1000)" json:"events"`
TokenFields []string `xorm:"varchar(1000)" json:"tokenFields"`
ObjectFields []string `xorm:"varchar(1000)" json:"objectFields"`
IsUserExtended bool `json:"isUserExtended"`
SingleOrgOnly bool `json:"singleOrgOnly"`
IsEnabled bool `json:"isEnabled"`

View File

@@ -66,6 +66,7 @@ func initAPI() {
beego.Router("/api/get-webhook-event", &controllers.ApiController{}, "GET:GetWebhookEventType")
beego.Router("/api/get-captcha-status", &controllers.ApiController{}, "GET:GetCaptchaStatus")
beego.Router("/api/callback", &controllers.ApiController{}, "POST:Callback")
beego.Router("/api/device-auth", &controllers.ApiController{}, "POST:DeviceAuth")
beego.Router("/api/get-organizations", &controllers.ApiController{}, "GET:GetOrganizations")
beego.Router("/api/get-organization", &controllers.ApiController{}, "GET:GetOrganization")

View File

@@ -89,7 +89,7 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, responseType, redirectUri, scope, state, nonce, codeChallenge, ctx.Request.Host, getAcceptLanguage(ctx))
code, err := object.GetOAuthCode(userId, clientId, "", responseType, redirectUri, scope, state, nonce, codeChallenge, ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err
} else if code.Message != "" {

View File

@@ -454,6 +454,7 @@ class ApplicationEditPage extends React.Component {
</Col>
<Col span={22} >
<Select virtual={false} disabled={this.state.application.tokenFormat !== "JWT-Custom"} mode="tags" showSearch style={{width: "100%"}} value={this.state.application.tokenFields} onChange={(value => {this.updateApplicationField("tokenFields", value);})}>
<Option key={"provider"} value={"provider"}>{"Provider"}</Option>)
{
Setting.getUserCommonFields().map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
@@ -726,6 +727,7 @@ class ApplicationEditPage extends React.Component {
{id: "token", name: "Token"},
{id: "id_token", name: "ID Token"},
{id: "refresh_token", name: "Refresh Token"},
{id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>

View File

@@ -119,6 +119,7 @@ class EntryPage extends React.Component {
<Route exact path="/login/:owner" render={(props) => this.renderHomeIfLoggedIn(<SelfLoginPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/signup/oauth/authorize" render={(props) => <SignupPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/login/oauth/authorize" render={(props) => <LoginPage {...this.props} application={this.state.application} type={"code"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/login/oauth/device/:userCode" render={(props) => <LoginPage {...this.props} application={this.state.application} type={"device"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/login/saml/authorize/:owner/:applicationName" render={(props) => <LoginPage {...this.props} application={this.state.application} type={"saml"} mode={"signin"} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/forget" render={(props) => <SelfForgetPage {...this.props} account={this.props.account} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/forget/:applicationName" render={(props) => <ForgetPage {...this.props} account={this.props.account} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />

View File

@@ -276,7 +276,7 @@ class OrganizationEditPage extends React.Component {
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.organization.passwordType} onChange={(value => {this.updateOrganizationField("passwordType", value);})}
options={["plain", "salt", "sha512-salt", "md5-salt", "bcrypt", "pbkdf2-salt", "argon2id"].map(item => Setting.getOption(item, item))}
options={["plain", "salt", "sha512-salt", "md5-salt", "bcrypt", "pbkdf2-salt", "argon2id", "pbkdf2-django"].map(item => Setting.getOption(item, item))}
/>
</Col>
</Row>

View File

@@ -1616,7 +1616,7 @@ export function isDarkTheme(themeAlgorithm) {
function getPreferredMfaProp(mfaProps) {
for (const i in mfaProps) {
if (mfaProps[i].isPreffered) {
if (mfaProps[i].isPreferred) {
return mfaProps[i];
}
}

View File

@@ -144,6 +144,9 @@ class WebhookEditPage extends React.Component {
if (["port"].includes(key)) {
value = Setting.myParseInt(value);
}
if (key === "objectFields") {
value = value.includes("All") ? ["All"] : value;
}
return value;
}
@@ -294,6 +297,19 @@ class WebhookEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:Object fields"), i18next.t("webhook:Object fields - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} mode="tags" showSearch style={{width: "100%"}} value={this.state.webhook.objectFields} onChange={(value => {this.updateWebhookField("objectFields", value);})}>
<Option key="All" value="All">{i18next.t("general:All")}</Option>
{
["owner", "name", "createdTime", "updatedTime", "deletedTime", "id", "displayName"].map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 19 : 2}>
{Setting.getLabel(i18next.t("webhook:Is user extended"), i18next.t("webhook:Is user extended - Tooltip"))} :

View File

@@ -61,7 +61,14 @@ export function oAuthParamsToQuery(oAuthParams) {
}
export function getApplicationLogin(params) {
const queryParams = (params?.type === "cas") ? casLoginParamsToQuery(params) : oAuthParamsToQuery(params);
let queryParams = "";
if (params?.type === "cas") {
queryParams = casLoginParamsToQuery(params);
} else if (params?.type === "device") {
queryParams = `?userCode=${params.userCode}&type=device`;
} else {
queryParams = oAuthParamsToQuery(params);
}
return fetch(`${authConfig.serverUrl}/api/get-app-login${queryParams}`, {
method: "GET",
credentials: "include",

View File

@@ -65,6 +65,8 @@ class LoginPage extends React.Component {
orgChoiceMode: new URLSearchParams(props.location?.search).get("orgChoiceMode") ?? null,
userLang: null,
loginLoading: false,
userCode: props.userCode ?? (props.match?.params?.userCode ?? null),
userCodeStatus: "",
};
if (this.state.type === "cas" && props.match?.params.casApplicationName !== undefined) {
@@ -81,7 +83,7 @@ class LoginPage extends React.Component {
if (this.getApplicationObj() === undefined) {
if (this.state.type === "login" || this.state.type === "saml") {
this.getApplication();
} else if (this.state.type === "code" || this.state.type === "cas") {
} else if (this.state.type === "code" || this.state.type === "cas" || this.state.type === "device") {
this.getApplicationLogin();
} else {
Setting.showMessage("error", `Unknown authentication type: ${this.state.type}`);
@@ -155,13 +157,25 @@ class LoginPage extends React.Component {
}
getApplicationLogin() {
const loginParams = (this.state.type === "cas") ? Util.getCasLoginParameters("admin", this.state.applicationName) : Util.getOAuthGetParameters();
let loginParams;
if (this.state.type === "cas") {
loginParams = Util.getCasLoginParameters("admin", this.state.applicationName);
} else if (this.state.type === "device") {
loginParams = {userCode: this.state.userCode, type: this.state.type};
} else {
loginParams = Util.getOAuthGetParameters();
}
AuthBackend.getApplicationLogin(loginParams)
.then((res) => {
if (res.status === "ok") {
const application = res.data;
this.onUpdateApplication(application);
} else {
if (this.state.type === "device") {
this.setState({
userCodeStatus: "expired",
});
}
this.onUpdateApplication(null);
this.setState({
msg: res.msg,
@@ -266,6 +280,9 @@ class LoginPage extends React.Component {
onUpdateApplication(application) {
this.props.onUpdateApplication(application);
if (application === null) {
return;
}
for (const idx in application.providers) {
const provider = application.providers[idx];
if (provider.provider?.category === "Face ID") {
@@ -296,6 +313,9 @@ class LoginPage extends React.Component {
const oAuthParams = Util.getOAuthGetParameters();
values["type"] = oAuthParams?.responseType ?? this.state.type;
if (this.state.userCode) {
values["userCode"] = this.state.userCode;
}
if (oAuthParams?.samlRequest) {
values["samlRequest"] = oAuthParams.samlRequest;
@@ -479,6 +499,11 @@ class LoginPage extends React.Component {
this.props.onLoginSuccess();
} else if (responseType === "code") {
this.postCodeLoginAction(res);
} else if (responseType === "device") {
Setting.showMessage("success", "Successful login");
this.setState({
userCodeStatus: "success",
});
} else if (responseType === "token" || responseType === "id_token") {
if (res.data2) {
sessionStorage.setItem("signinUrl", window.location.pathname + window.location.search);
@@ -826,6 +851,16 @@ class LoginPage extends React.Component {
);
}
if (this.state.userCode && this.state.userCodeStatus === "success") {
return (
<Result
status="success"
title={i18next.t("application:Logged in successfully")}
>
</Result>
);
}
const showForm = Setting.isPasswordEnabled(application) || Setting.isCodeSigninEnabled(application) || Setting.isWebAuthnEnabled(application) || Setting.isLdapEnabled(application) || Setting.isFaceIdEnabled(application);
if (showForm) {
let loginWidth = 320;
@@ -986,6 +1021,10 @@ class LoginPage extends React.Component {
return null;
}
if (this.state.userCode && this.state.userCodeStatus === "success") {
return null;
}
return (
<div>
<div style={{fontSize: 16, textAlign: "left"}}>
@@ -1070,6 +1109,8 @@ class LoginPage extends React.Component {
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}${error}`);
});
}).catch(error => {
Setting.showMessage("error", `${error}`);
}).finally(() => {
this.setState({
loginLoading: false,
@@ -1266,6 +1307,15 @@ class LoginPage extends React.Component {
}
render() {
if (this.state.userCodeStatus === "expired") {
return <Result
style={{width: "100%"}}
status="error"
title={`Code ${i18next.t("subscription:Expired")}`}
>
</Result>;
}
const application = this.getApplicationObj();
if (application === undefined) {
return null;