Compare commits

...

23 Commits

Author SHA1 Message Date
87506b84e3 feat: support special chars like "+" in username parameter of /api/get-email-and-phone API (#3824) 2025-05-23 17:29:00 +08:00
fed9332246 feat: can configure Domain field in Nextcloud OAuth provider (#3813) 2025-05-23 17:23:34 +08:00
33afc52a0b feat: can redirect user to login page after linking provider in prompt page (#3820) 2025-05-23 07:15:53 +08:00
9035ca365a feat: improve Indonesia i18n translations (#3817) 2025-05-22 20:42:47 +08:00
b97ae72179 feat: use the standard user struct for JWT-Standard to get a correct userinfo (#3809) 2025-05-21 18:54:42 +08:00
9190db1099 feat: fix bug that token endpoint doesn't return 400/401 when type is object.TokenError (#3808) 2025-05-20 10:39:55 +08:00
1173f75794 feat: return HTTP status 400 instead of 200 in GetOAuthToken() (#3807) 2025-05-20 01:05:43 +08:00
086859d1ce feat: change User.Avatar length back to 500 2025-05-18 09:47:56 +08:00
9afaf5d695 feat: increase User.Avatar length to 1000 2025-05-17 19:59:17 +08:00
521f90a603 feat: fix access_token endpoint cannot read clientId in form when using device code flow (#3800) 2025-05-17 18:53:38 +08:00
4260efcfd0 feat: add useIdAsName field for WeCom OAuth provider (#3797) 2025-05-17 02:27:06 +08:00
d772b0b7a8 feat: fix bug that username will be random with useEmailAsUsername enabled (#3793) 2025-05-16 18:40:50 +08:00
702b390da1 feat: fix MFA preference doesn't work bug (#3790) 2025-05-15 21:04:36 +08:00
b15b3b9335 feat: support adapter in app.conf logConfig (#3784) 2025-05-14 08:27:11 +08:00
f8f864c5b9 feat: add logged-in IDP provider info to access token (#3776) 2025-05-11 09:51:51 +08:00
90e790f83c feat: increase Application.SamlReplyUrl from 100 chars to 500 2025-05-10 22:42:40 +08:00
58413246f3 feat: fix bug that db not found error in createDatabaseForPostgres (#3765) 2025-05-05 18:25:58 +08:00
8f307dd907 feat: upgrade go-teams-notify to v2.13.0 2025-05-05 01:02:27 +08:00
fe42b5e0ba feat: improve checkGroupName() (#3759) 2025-05-03 22:47:42 +08:00
383bf44391 feat: support OIDC device flow: "/api/device-auth" (#3757) 2025-04-30 23:42:26 +08:00
36f5de3203 feat: allow jwks to include the certs from non-admin owner (#3749) 2025-04-28 09:31:56 +08:00
eae69c41d7 feat: add object field filter for webhook (#3746) 2025-04-26 22:05:36 +08:00
91057f54f3 feat: add Pbkdf2DjangoCredManager (#3745) 2025-04-25 16:16:50 +08:00
43 changed files with 582 additions and 105 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,13 @@ 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 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 +221,46 @@ 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.SetTokenErrorHttpStatus()
c.ServeJSON()
c.SetTokenErrorHttpStatus()
return
}
deviceAuthCacheCast := deviceAuthCache.(object.DeviceAuthCache)
if !deviceAuthCacheCast.UserSignIn {
c.Data["json"] = &object.TokenError{
Error: "authorization_pending",
ErrorDescription: "authorization pending",
}
c.SetTokenErrorHttpStatus()
c.ServeJSON()
c.SetTokenErrorHttpStatus()
return
}
if deviceAuthCacheCast.RequestAt.Add(time.Second * 120).Before(time.Now()) {
c.Data["json"] = &object.TokenError{
Error: "expired_token",
ErrorDescription: "token is expired",
}
c.SetTokenErrorHttpStatus()
c.ServeJSON()
c.SetTokenErrorHttpStatus()
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=

View File

@ -1,9 +1,9 @@
{
"account": {
"Failed to add user": "Gagal menambahkan pengguna",
"Get init score failed, error: %w": "Gagal mendapatkan nilai init, kesalahan: %w",
"Get init score failed, error: %w": "Gagal mendapatkan nilai inisiasi, kesalahan: %w",
"Please sign out first": "Silakan keluar terlebih dahulu",
"The application does not allow to sign up new account": "Aplikasi tidak memperbolehkan untuk mendaftar akun baru"
"The application does not allow to sign up new account": "Aplikasi tidak memperbolehkan pendaftaran akun baru"
},
"auth": {
"Challenge method should be S256": "Metode tantangan harus S256",
@ -13,17 +13,17 @@
"State expected: %s, but got: %s": "Diharapkan: %s, tapi diperoleh: %s",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account via %%s, please use another way to sign up": "Akun untuk penyedia: %s dan nama pengguna: %s (%s) tidak ada dan tidak diizinkan untuk mendaftar sebagai akun baru melalui %%s, silakan gunakan cara lain untuk mendaftar",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account, please contact your IT support": "Akun untuk penyedia: %s dan nama pengguna: %s (%s) tidak ada dan tidak diizinkan untuk mendaftar sebagai akun baru, silakan hubungi dukungan IT Anda",
"The account for provider: %s and username: %s (%s) is already linked to another account: %s (%s)": "Akun untuk provider: %s dan username: %s (%s) sudah terhubung dengan akun lain: %s (%s)",
"The account for provider: %s and username: %s (%s) is already linked to another account: %s (%s)": "Akun untuk penyedia: %s dan username: %s (%s) sudah terhubung dengan akun lain: %s (%s)",
"The application: %s does not exist": "Aplikasi: %s tidak ada",
"The login method: login with LDAP is not enabled for the application": "The login method: login with LDAP is not enabled for the application",
"The login method: login with SMS is not enabled for the application": "The login method: login with SMS is not enabled for the application",
"The login method: login with email is not enabled for the application": "The login method: login with email is not enabled for the application",
"The login method: login with face is not enabled for the application": "The login method: login with face is not enabled for the application",
"The login method: login with password is not enabled for the application": "Metode login: login dengan kata sandi tidak diaktifkan untuk aplikasi tersebut",
"The login method: login with password is not enabled for the application": "Metode login: login dengan sandi tidak diaktifkan untuk aplikasi tersebut",
"The organization: %s does not exist": "The organization: %s does not exist",
"The provider: %s is not enabled for the application": "Penyedia: %s tidak diaktifkan untuk aplikasi ini",
"Unauthorized operation": "Operasi tidak sah",
"Unknown authentication type (not password or provider), form = %s": "Jenis otentikasi tidak diketahui (bukan kata sandi atau pemberi), formulir = %s",
"Unknown authentication type (not password or provider), form = %s": "Jenis otentikasi tidak diketahui (bukan sandi atau penyedia), formulir = %s",
"User's tag: %s is not listed in the application's tags": "User's tag: %s is not listed in the application's tags",
"paid-user %s does not have active or pending subscription and the application: %s does not have default pricing": "paid-user %s does not have active or pending subscription and the application: %s does not have default pricing"
},
@ -39,59 +39,59 @@
"Email cannot be empty": "Email tidak boleh kosong",
"Email is invalid": "Email tidak valid",
"Empty username.": "Nama pengguna kosong.",
"Face data does not exist, cannot log in": "Face data does not exist, cannot log in",
"Face data mismatch": "Face data mismatch",
"Face data does not exist, cannot log in": "Data wajah tidak ada, tidak bisa login",
"Face data mismatch": "Ketidakcocokan data wajah",
"FirstName cannot be blank": "Nama depan tidak boleh kosong",
"Invitation code cannot be blank": "Invitation code cannot be blank",
"Invitation code exhausted": "Invitation code exhausted",
"Invitation code is invalid": "Invitation code is invalid",
"Invitation code suspended": "Invitation code suspended",
"LDAP user name or password incorrect": "Nama pengguna atau kata sandi Ldap salah",
"Invitation code cannot be blank": "Kode undangan tidak boleh kosong",
"Invitation code exhausted": "Kode undangan habis",
"Invitation code is invalid": "Kode undangan tidak valid",
"Invitation code suspended": "Kode undangan ditangguhkan",
"LDAP user name or password incorrect": "Nama pengguna atau sandi LDAP salah",
"LastName cannot be blank": "Nama belakang tidak boleh kosong",
"Multiple accounts with same uid, please check your ldap server": "Beberapa akun dengan uid yang sama, harap periksa server ldap Anda",
"Multiple accounts with same uid, please check your ldap server": "Beberapa akun dengan uid yang sama, harap periksa server LDAP Anda",
"Organization does not exist": "Organisasi tidak ada",
"Phone already exists": "Telepon sudah ada",
"Phone cannot be empty": "Telepon tidak boleh kosong",
"Phone number is invalid": "Nomor telepon tidak valid",
"Please register using the email corresponding to the invitation code": "Please register using the email corresponding to the invitation code",
"Please register using the phone corresponding to the invitation code": "Please register using the phone corresponding to the invitation code",
"Please register using the username corresponding to the invitation code": "Please register using the username corresponding to the invitation code",
"Session outdated, please login again": "Sesi kedaluwarsa, silakan masuk lagi",
"The invitation code has already been used": "The invitation code has already been used",
"Please register using the email corresponding to the invitation code": "Silakan mendaftar menggunakan email yang sesuai dengan kode undangan",
"Please register using the phone corresponding to the invitation code": "Silakan mendaftar menggunakan email yang sesuai dengan kode undangan",
"Please register using the username corresponding to the invitation code": "Silakan mendaftar menggunakan username yang sesuai dengan kode undangan",
"Session outdated, please login again": "Sesi kadaluwarsa, silakan masuk lagi",
"The invitation code has already been used": "Kode undangan sudah digunakan",
"The user is forbidden to sign in, please contact the administrator": "Pengguna dilarang masuk, silakan hubungi administrator",
"The user: %s doesn't exist in LDAP server": "The user: %s doesn't exist in LDAP server",
"The user: %s doesn't exist in LDAP server": "Pengguna: %s tidak ada di server LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Nama pengguna hanya bisa menggunakan karakter alfanumerik, garis bawah atau tanda hubung, tidak boleh memiliki dua tanda hubung atau garis bawah berurutan, dan tidak boleh diawali atau diakhiri dengan tanda hubung atau garis bawah.",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"",
"The value \\\"%s\\\" for account field \\\"%s\\\" doesn't match the account item regex": "Nilai \\\"%s\\\" pada bidang akun \\\"%s\\\" tidak cocok dengan ketentuan",
"The value \\\"%s\\\" for signup field \\\"%s\\\" doesn't match the signup item regex of the application \\\"%s\\\"": "Nilai \\\"%s\\\" pada bidang pendaftaran \\\"%s\\\" tidak cocok dengan ketentuan aplikasi \\\"%s\\\"",
"Username already exists": "Nama pengguna sudah ada",
"Username cannot be an email address": "Username tidak bisa menjadi alamat email",
"Username cannot contain white spaces": "Username tidak boleh mengandung spasi",
"Username cannot start with a digit": "Username tidak dapat dimulai dengan angka",
"Username is too long (maximum is 255 characters).": "Nama pengguna terlalu panjang (maksimum 255 karakter).",
"Username must have at least 2 characters": "Nama pengguna harus memiliki setidaknya 2 karakter",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Anda telah memasukkan kata sandi atau kode yang salah terlalu banyak kali, mohon tunggu selama %d menit dan coba lagi",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Anda telah memasukkan sandi atau kode yang salah terlalu sering, mohon tunggu selama %d menit lalu coba kembali",
"Your region is not allow to signup by phone": "Wilayah Anda tidak diizinkan untuk mendaftar melalui telepon",
"password or code is incorrect": "password or code is incorrect",
"password or code is incorrect, you have %d remaining chances": "Kata sandi atau kode salah, Anda memiliki %d kesempatan tersisa",
"password or code is incorrect": "kata sandi atau kode salah",
"password or code is incorrect, you have %d remaining chances": "Sandi atau kode salah, Anda memiliki %d kesempatan tersisa",
"unsupported password type: %s": "jenis sandi tidak didukung: %s"
},
"general": {
"Missing parameter": "Parameter hilang",
"Please login first": "Silahkan login terlebih dahulu",
"The organization: %s should have one application at least": "The organization: %s should have one application at least",
"The organization: %s should have one application at least": "Organisasi: %s setidaknya harus memiliki satu aplikasi",
"The user: %s doesn't exist": "Pengguna: %s tidak ada",
"don't support captchaProvider: ": "Jangan mendukung captchaProvider:",
"this operation is not allowed in demo mode": "this operation is not allowed in demo mode",
"this operation requires administrator to perform": "this operation requires administrator to perform"
"this operation is not allowed in demo mode": "tindakan ini tidak diizinkan pada mode demo",
"this operation requires administrator to perform": "tindakan ini membutuhkan peran administrator"
},
"ldap": {
"Ldap server exist": "Server ldap ada"
},
"link": {
"Please link first": "Tolong tautkan terlebih dahulu",
"Please link first": "Silahkan tautkan terlebih dahulu",
"This application has no providers": "Aplikasi ini tidak memiliki penyedia",
"This application has no providers of type": " Aplikasi ini tidak memiliki penyedia tipe ",
"This provider can't be unlinked": "Pemberi layanan ini tidak dapat dipisahkan",
"This provider can't be unlinked": "Penyedia layanan ini tidak dapat dipisahkan",
"You are not the global admin, you can't unlink other users": "Anda bukan admin global, Anda tidak dapat memutuskan tautan pengguna lain",
"You can't unlink yourself, you are not a member of any application": "Anda tidak dapat memutuskan tautan diri sendiri, karena Anda bukan anggota dari aplikasi apa pun"
},
@ -101,11 +101,11 @@
"Unknown modify rule %s.": "Aturan modifikasi tidak diketahui %s."
},
"permission": {
"The permission: \\\"%s\\\" doesn't exist": "The permission: \\\"%s\\\" doesn't exist"
"The permission: \\\"%s\\\" doesn't exist": "Izin: \\\"%s\\\" tidak ada"
},
"provider": {
"Invalid application id": "ID aplikasi tidak valid",
"the provider: %s does not exist": "provider: %s tidak ada"
"the provider: %s does not exist": "penyedia: %s tidak ada"
},
"resource": {
"User is nil for tag: avatar": "Pengguna kosong untuk tag: avatar",
@ -129,13 +129,13 @@
"token": {
"Grant_type: %s is not supported in this application": "Jenis grant (grant_type) %s tidak didukung dalam aplikasi ini",
"Invalid application or wrong clientSecret": "Aplikasi tidak valid atau clientSecret salah",
"Invalid client_id": "Invalid client_id = ID klien tidak valid",
"Invalid client_id": "ID klien tidak valid",
"Redirect URI: %s doesn't exist in the allowed Redirect URI list": "URI pengalihan: %s tidak ada dalam daftar URI Pengalihan yang diizinkan",
"Token not found, invalid accessToken": "Token tidak ditemukan, accessToken tidak valid"
},
"user": {
"Display name cannot be empty": "Nama tampilan tidak boleh kosong",
"New password cannot contain blank space.": "Kata sandi baru tidak boleh mengandung spasi kosong."
"New password cannot contain blank space.": "Sandi baru tidak boleh mengandung spasi kosong."
},
"user_upload": {
"Failed to import users": "Gagal mengimpor pengguna"
@ -148,16 +148,16 @@
"verification": {
"Invalid captcha provider.": "Penyedia captcha tidak valid.",
"Phone number is invalid in your region %s": "Nomor telepon tidak valid di wilayah anda %s",
"The verification code has not been sent yet!": "The verification code has not been sent yet!",
"The verification code has not been sent yet, or has already been used!": "The verification code has not been sent yet, or has already been used!",
"The verification code has not been sent yet!": "Kode verifikasi belum terkirim!",
"The verification code has not been sent yet, or has already been used!": "Kode verifikasi belum dikirim atau telah digunakan!",
"Turing test failed.": "Tes Turing gagal.",
"Unable to get the email modify rule.": "Tidak dapat memperoleh aturan modifikasi email.",
"Unable to get the phone modify rule.": "Tidak dapat memodifikasi aturan telepon.",
"Unknown type": "Tipe tidak diketahui",
"Wrong verification code!": "Kode verifikasi salah!",
"You should verify your code in %d min!": "Anda harus memverifikasi kode Anda dalam %d menit!",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "please add a SMS provider to the \\\"Providers\\\" list for the application: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "please add an Email provider to the \\\"Providers\\\" list for the application: %s",
"please add a SMS provider to the \\\"Providers\\\" list for the application: %s": "silahkan tambahkan penyedia SMS ke daftar \\\"Penyedia\\\" untuk aplikasi: %s",
"please add an Email provider to the \\\"Providers\\\" list for the application: %s": "silahkan tambahkan penyedia Email ke daftar \\\"Penyedia\\\" untuk aplikasi: %s",
"the user does not exist, please sign up first": "Pengguna tidak ada, silakan daftar terlebih dahulu"
},
"webauthn": {

View File

@ -278,9 +278,16 @@ func NewGothIdProvider(providerType string, clientId string, clientSecret string
Session: &naver.Session{},
}
case "Nextcloud":
idp = GothIdProvider{
Provider: nextcloud.New(clientId, clientSecret, redirectUrl),
Session: &nextcloud.Session{},
if hostUrl != "" {
idp = GothIdProvider{
Provider: nextcloud.NewCustomisedDNS(clientId, clientSecret, redirectUrl, hostUrl),
Session: &nextcloud.Session{},
}
} else {
idp = GothIdProvider{
Provider: nextcloud.New(clientId, clientSecret, redirectUrl),
Session: &nextcloud.Session{},
}
}
case "OneDrive":
idp = GothIdProvider{

View File

@ -44,6 +44,7 @@ type ProviderInfo struct {
AppId string
HostUrl string
RedirectUrl string
DisableSsl bool
TokenURL string
AuthURL string
@ -79,9 +80,9 @@ func GetIdProvider(idpInfo *ProviderInfo, redirectUrl string) (IdProvider, error
return NewLinkedInIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
case "WeCom":
if idpInfo.SubType == "Internal" {
return NewWeComInternalIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
return NewWeComInternalIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl, idpInfo.DisableSsl), nil
} else if idpInfo.SubType == "Third-party" {
return NewWeComIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl), nil
return NewWeComIdProvider(idpInfo.ClientId, idpInfo.ClientSecret, redirectUrl, idpInfo.DisableSsl), nil
} else {
return nil, fmt.Errorf("WeCom provider subType: %s is not supported", idpInfo.SubType)
}

View File

@ -29,13 +29,16 @@ import (
type WeComInternalIdProvider struct {
Client *http.Client
Config *oauth2.Config
UseIdAsName bool
}
func NewWeComInternalIdProvider(clientId string, clientSecret string, redirectUrl string) *WeComInternalIdProvider {
func NewWeComInternalIdProvider(clientId string, clientSecret string, redirectUrl string, useIdAsName bool) *WeComInternalIdProvider {
idp := &WeComInternalIdProvider{}
config := idp.getConfig(clientId, clientSecret, redirectUrl)
idp.Config = config
idp.UseIdAsName = useIdAsName
return idp
}
@ -169,5 +172,9 @@ func (idp *WeComInternalIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo,
userInfo.Id = userInfo.Username
}
if idp.UseIdAsName {
userInfo.Username = userInfo.Id
}
return &userInfo, nil
}

View File

@ -28,13 +28,16 @@ import (
type WeComIdProvider struct {
Client *http.Client
Config *oauth2.Config
UseIdAsName bool
}
func NewWeComIdProvider(clientId string, clientSecret string, redirectUrl string) *WeComIdProvider {
func NewWeComIdProvider(clientId string, clientSecret string, redirectUrl string, useIdAsName bool) *WeComIdProvider {
idp := &WeComIdProvider{}
config := idp.getConfig(clientId, clientSecret, redirectUrl)
idp.Config = config
idp.UseIdAsName = useIdAsName
return idp
}
@ -183,6 +186,10 @@ func (idp *WeComIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, error)
DisplayName: wecomUserInfo.UserInfo.Name,
AvatarUrl: wecomUserInfo.UserInfo.Avatar,
}
if idp.UseIdAsName {
userInfo.Username = userInfo.Id
}
return &userInfo, nil
}

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

@ -475,6 +475,7 @@ func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.Provid
AuthURL: provider.CustomAuthUrl,
UserInfoURL: provider.CustomUserInfoUrl,
UserMapping: provider.UserMapping,
DisableSsl: provider.DisableSsl,
}
if provider.Type == "WeChat" {

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
}
@ -46,6 +47,17 @@ type UserShort struct {
Phone string `xorm:"varchar(100) index" json:"phone"`
}
type UserStandard struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"preferred_username,omitempty"`
Id string `xorm:"varchar(100) index" json:"id"`
DisplayName string `xorm:"varchar(100)" json:"name,omitempty"`
Avatar string `xorm:"varchar(500)" json:"picture,omitempty"`
Email string `xorm:"varchar(100) index" json:"email,omitempty"`
Phone string `xorm:"varchar(100) index" json:"phone,omitempty"`
}
type UserWithoutThirdIdp struct {
Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
Name string `xorm:"varchar(100) notnull pk" json:"name"`
@ -140,6 +152,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 +172,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
}
@ -176,6 +190,20 @@ func getShortUser(user *User) *UserShort {
return res
}
func getStandardUser(user *User) *UserStandard {
res := &UserStandard{
Owner: user.Owner,
Name: user.Name,
Id: user.Id,
DisplayName: user.DisplayName,
Avatar: user.Avatar,
Email: user.Email,
Phone: user.Phone,
}
return res
}
func getUserWithoutThirdIdp(user *User) *UserWithoutThirdIdp {
res := &UserWithoutThirdIdp{
Owner: user.Owner,
@ -274,6 +302,7 @@ func getShortClaims(claims Claims) ClaimsShort {
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
return res
}
@ -287,6 +316,7 @@ func getClaimsWithoutThirdIdp(claims Claims) ClaimsWithoutThirdIdp {
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
return res
}
@ -308,6 +338,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 +373,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 +393,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

@ -23,7 +23,7 @@ import (
)
type ClaimsStandard struct {
*UserShort
*UserStandard
EmailVerified bool `json:"email_verified,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
PhoneNumberVerified bool `json:"phone_number_verified,omitempty"`
@ -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
}
@ -47,13 +48,14 @@ func getStreetAddress(user *User) string {
func getStandardClaims(claims Claims) ClaimsStandard {
res := ClaimsStandard{
UserShort: getShortUser(claims.User),
UserStandard: getStandardUser(claims.User),
EmailVerified: claims.User.EmailVerified,
TokenType: claims.TokenType,
Nonce: claims.Nonce,
Scope: claims.Scope,
RegisteredClaims: claims.RegisteredClaims,
Azp: claims.Azp,
Provider: claims.Provider,
}
res.Phone = ""

View File

@ -837,7 +837,7 @@ func AddUser(user *User) (bool, error) {
return false, fmt.Errorf("the user's owner and name should not be empty")
}
if CheckUsername(user.Name, "en") != "" {
if CheckUsernameWithEmail(user.Name, "en") != "" {
user.Name = util.GetRandomName()
}

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

@ -692,23 +692,35 @@ class ProviderEditPage extends React.Component {
</Row>
{
this.state.provider.type !== "WeCom" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.method} onChange={value => {
this.updateProviderField("method", value);
}}>
{
[
{id: "Normal", name: i18next.t("provider:Normal")},
{id: "Silent", name: i18next.t("provider:Silent")},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>)
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={this.state.provider.method} onChange={value => {
this.updateProviderField("method", value);
}}>
{
[
{id: "Normal", name: i18next.t("provider:Normal")},
{id: "Silent", name: i18next.t("provider:Silent")},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Use id as name"), i18next.t("provider:Use id as name - Tooltip"))} :
</Col>
<Col span={22} >
<Switch checked={this.state.provider.disableSsl} onChange={checked => {
this.updateProviderField("disableSsl", checked);
}} />
</Col>
</Row>
</React.Fragment>)
}
</React.Fragment>
)
@ -938,7 +950,7 @@ class ProviderEditPage extends React.Component {
)
}
{
this.state.provider.type !== "ADFS" && this.state.provider.type !== "AzureAD" && this.state.provider.type !== "AzureADB2C" && (this.state.provider.type !== "Casdoor" && this.state.category !== "Storage") && this.state.provider.type !== "Okta" ? null : (
this.state.provider.type !== "ADFS" && this.state.provider.type !== "AzureAD" && this.state.provider.type !== "AzureADB2C" && (this.state.provider.type !== "Casdoor" && this.state.category !== "Storage") && this.state.provider.type !== "Okta" && this.state.provider.type !== "Nextcloud" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :

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

@ -37,7 +37,7 @@ export function signup(values) {
}
export function getEmailAndPhone(organization, username) {
return fetch(`${authConfig.serverUrl}/api/get-email-and-phone?organization=${organization}&username=${username}`, {
return fetch(`${authConfig.serverUrl}/api/get-email-and-phone?organization=${organization}&username=${encodeURIComponent(username)}`, {
method: "GET",
credentials: "include",
headers: {
@ -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

@ -193,7 +193,11 @@ class AuthCallback extends React.Component {
const token = res.data;
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}${responseType}=${token}&state=${oAuthParams.state}&token_type=bearer`);
} else if (responseType === "link") {
const from = innerParams.get("from");
let from = innerParams.get("from");
const oauth = innerParams.get("oauth");
if (oauth) {
from += `?oauth=${oauth}`;
}
Setting.goToLinkSoftOrJumpSelf(this, from);
} else if (responseType === "saml") {
if (res.data2.method === "POST") {

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"}}>
@ -1268,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;

View File

@ -194,8 +194,10 @@ class PromptPage extends React.Component {
const redirectUri = params.get("redirectUri");
const code = params.get("code");
const state = params.get("state");
const oauth = params.get("oauth");
if (redirectUri === null || code === null || state === null) {
return "";
const signInUrl = sessionStorage.getItem("signinUrl");
return oauth === "true" ? signInUrl : "";
}
return `${redirectUri}?code=${code}&state=${state}`;
}

View File

@ -402,6 +402,10 @@ export function getAuthUrl(application, provider, method, code) {
redirectUri = `${redirectOrigin}/api/callback`;
} else if (provider.type === "Google" && provider.disableSsl) {
scope += "+https://www.googleapis.com/auth/user.phonenumbers.read";
} else if (provider.type === "Nextcloud") {
if (provider.domain) {
endpoint = `${provider.domain}/apps/oauth2/authorize`;
}
}
if (provider.type === "Google" || provider.type === "GitHub" || provider.type === "Facebook"

View File

@ -195,8 +195,9 @@ class SignupPage extends React.Component {
if (authConfig.appName === application.name) {
return "/result";
} else {
const oAuthParams = Util.getOAuthGetParameters();
if (Setting.hasPromptPage(application)) {
return `/prompt/${application.name}`;
return `/prompt/${application.name}?oauth=${oAuthParams !== null}`;
} else {
return `/result/${application.name}`;
}