mirror of
https://gitee.com/dromara/mayfly-go
synced 2025-11-02 15:30:25 +08:00
feat: 实现 LDAP 登录
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,4 +23,4 @@ out
|
||||
server/docs/docker-compose
|
||||
server/config.yml
|
||||
|
||||
mayfly-go.log
|
||||
mayfly-go.log
|
||||
5164
mayfly_go_web/package-lock.json
generated
5164
mayfly_go_web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,4 +11,6 @@ export default {
|
||||
logout: () => request.post('/auth/accounts/logout'),
|
||||
getPermissions: () => request.get('/sys/accounts/permissions'),
|
||||
oauth2Callback: (params: any) => request.get('/auth/oauth2/callback', params),
|
||||
getLdapEnabled: () => request.get("/auth/ldap/enabled"),
|
||||
ldapLogin: (param: any) => request.post('/auth/ldap/login', param),
|
||||
};
|
||||
|
||||
@@ -67,3 +67,14 @@ function convertBool(value: string, defaultValue: boolean) {
|
||||
}
|
||||
return value == '1' || value == 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取LDAP登录配置
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export async function getLdapEnabled(): Promise<any> {
|
||||
const value = await openApi.getLdapEnabled();
|
||||
return convertBool(value, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="ldapEnabled" prop="ldapLogin">
|
||||
<el-checkbox v-model="loginForm.ldapLogin" label="LDAP 登录" size="small"/>
|
||||
</el-form-item>
|
||||
<span v-if="showLoginFailTips" style="color: #f56c6c; font-size: 12px">
|
||||
提示:登录失败超过{{ accountLoginSecurity.loginFailCount }}次后将被限制{{ accountLoginSecurity.loginFailMin }}分钟内不可再次登录
|
||||
</span>
|
||||
@@ -133,7 +136,7 @@ import { getSession, setSession, setUserInfo2Session, setUseWatermark2Session }
|
||||
import { formatAxis } from '@/common/utils/format';
|
||||
import openApi from '@/common/openApi';
|
||||
import { RsaEncrypt } from '@/common/rsa';
|
||||
import { getAccountLoginSecurity, useWartermark } from '@/common/sysconfig';
|
||||
import {getAccountLoginSecurity, getLdapEnabled, useWartermark} from '@/common/sysconfig';
|
||||
import { letterAvatar } from '@/common/utils/string';
|
||||
import { useUserInfo } from '@/store/userInfo';
|
||||
import QrcodeVue from 'qrcode.vue';
|
||||
@@ -168,6 +171,7 @@ const state = reactive({
|
||||
password: '',
|
||||
captcha: '',
|
||||
cid: '',
|
||||
ldapLogin: false,
|
||||
},
|
||||
loginRes: {} as any,
|
||||
changePwdDialog: {
|
||||
@@ -223,9 +227,10 @@ const state = reactive({
|
||||
otpConfirm: false,
|
||||
updateUserConfirm: false,
|
||||
},
|
||||
ldapEnabled: false,
|
||||
});
|
||||
|
||||
const { accountLoginSecurity, showLoginFailTips, captchaImage, loginForm, changePwdDialog, otpDialog, baseInfoDialog, loading } = toRefs(state);
|
||||
const { accountLoginSecurity, showLoginFailTips, captchaImage, loginForm, changePwdDialog, otpDialog, baseInfoDialog, loading, ldapEnabled } = toRefs(state);
|
||||
|
||||
onMounted(async () => {
|
||||
nextTick(async () => {
|
||||
@@ -234,6 +239,10 @@ onMounted(async () => {
|
||||
state.accountLoginSecurity = res;
|
||||
}
|
||||
getCaptcha();
|
||||
|
||||
const ldap = await getLdapEnabled();
|
||||
state.ldapEnabled = ldap;
|
||||
state.loginForm.ldapLogin = ldap
|
||||
});
|
||||
// 移除公钥, 方便后续重新获取
|
||||
sessionStorage.removeItem('RsaPublicKey');
|
||||
@@ -288,7 +297,11 @@ const onSignIn = async () => {
|
||||
try {
|
||||
const loginReq = { ...state.loginForm };
|
||||
loginReq.password = await RsaEncrypt(originPwd);
|
||||
loginRes = await openApi.login(loginReq);
|
||||
if (state.loginForm.ldapLogin) {
|
||||
loginRes = await openApi.ldapLogin(loginReq);
|
||||
} else {
|
||||
loginRes = await openApi.login(loginReq);
|
||||
}
|
||||
} catch (e: any) {
|
||||
state.loading.signIn = false;
|
||||
state.loginForm.captcha = '';
|
||||
|
||||
@@ -124,7 +124,7 @@ const oauth2Login = () => {
|
||||
box-shadow: 0 2px 12px 0 var(--color-primary-light-5);
|
||||
border-radius: 4px;
|
||||
transition: height 0.2s linear;
|
||||
height: 480px;
|
||||
height: 490px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
|
||||
@@ -37,4 +37,16 @@ log:
|
||||
level: info
|
||||
# file:
|
||||
# path: ./
|
||||
# name: mayfly-go.log
|
||||
# name: mayfly-go.log
|
||||
ldap:
|
||||
enabled: true
|
||||
host: "ldap.example.com"
|
||||
port: 389
|
||||
baseDn: "ou=users,dc=example,dc=com"
|
||||
bindDn: "cn=admin,dc=example,dc=com"
|
||||
userFilter: "(&(objectClass=organizationalPerson)(uid=%s))"
|
||||
bindPassword: "admin123."
|
||||
fieldMapping:
|
||||
identifier: "cn"
|
||||
displayName: "displayName"
|
||||
email: "mail"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package form
|
||||
|
||||
type LoginForm struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `binding:"required"`
|
||||
Captcha string `json:"captcha"`
|
||||
Cid string `json:"cid"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `binding:"required"`
|
||||
Captcha string `json:"captcha"`
|
||||
Cid string `json:"cid"`
|
||||
LdapLogin bool `json:"ldapLogin"`
|
||||
}
|
||||
|
||||
type OtpVerfiy struct {
|
||||
|
||||
109
server/internal/auth/api/ldap_login.go
Normal file
109
server/internal/auth/api/ldap_login.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
"mayfly-go/internal/auth/api/form"
|
||||
msgapp "mayfly-go/internal/msg/application"
|
||||
sysapp "mayfly-go/internal/sys/application"
|
||||
sysentity "mayfly-go/internal/sys/domain/entity"
|
||||
"mayfly-go/pkg/biz"
|
||||
"mayfly-go/pkg/cache"
|
||||
"mayfly-go/pkg/captcha"
|
||||
"mayfly-go/pkg/config"
|
||||
"mayfly-go/pkg/ginx"
|
||||
"mayfly-go/pkg/ldap"
|
||||
"mayfly-go/pkg/req"
|
||||
"mayfly-go/pkg/utils/cryptox"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LdapLogin struct {
|
||||
AccountApp sysapp.Account
|
||||
MsgApp msgapp.Msg
|
||||
ConfigApp sysapp.Config
|
||||
}
|
||||
|
||||
// @router /auth/ldap/enabled [get]
|
||||
func (a *LdapLogin) GetLdapEnabled(rc *req.Ctx) {
|
||||
rc.ResData = config.Conf.Ldap.Enabled
|
||||
}
|
||||
|
||||
// @router /auth/ldap/login [post]
|
||||
func (a *LdapLogin) Login(rc *req.Ctx) {
|
||||
loginForm := ginx.BindJsonAndValid(rc.GinCtx, new(form.LoginForm))
|
||||
|
||||
// 确认是 LDAP 登录
|
||||
biz.IsTrue(loginForm.LdapLogin, "LDAP 登录参数错误")
|
||||
|
||||
accountLoginSecurity := a.ConfigApp.GetConfig(sysentity.ConfigKeyAccountLoginSecurity).ToAccountLoginSecurity()
|
||||
// 判断是否有开启登录验证码校验
|
||||
if accountLoginSecurity.UseCaptcha {
|
||||
// 校验验证码
|
||||
biz.IsTrue(captcha.Verify(loginForm.Cid, loginForm.Captcha), "验证码错误")
|
||||
}
|
||||
|
||||
username := loginForm.Username
|
||||
|
||||
clientIp := getIpAndRegion(rc)
|
||||
rc.ReqParam = fmt.Sprintf("username: %s | ip: %s", username, clientIp)
|
||||
|
||||
originPwd, err := cryptox.DefaultRsaDecrypt(loginForm.Password, true)
|
||||
biz.ErrIsNilAppendErr(err, "解密密码错误: %s")
|
||||
// LDAP 用户本地密码为空,不允许本地登录
|
||||
biz.NotEmpty(originPwd, "密码不能为空")
|
||||
|
||||
failCountKey := fmt.Sprintf("account:login:failcount:%s", username)
|
||||
nowFailCount := cache.GetInt(failCountKey)
|
||||
loginFailCount := accountLoginSecurity.LoginFailCount
|
||||
loginFailMin := accountLoginSecurity.LoginFailMin
|
||||
biz.IsTrue(nowFailCount < loginFailCount, "登录失败超过%d次, 请%d分钟后再试", loginFailCount, loginFailMin)
|
||||
|
||||
var account *sysentity.Account
|
||||
cols := []string{"Id", "Name", "Username", "Password", "Status", "LastLoginTime", "LastLoginIp", "OtpSecret"}
|
||||
account, err = a.getOrCreateUserWithLdap(username, originPwd, cols...)
|
||||
|
||||
if err != nil {
|
||||
nowFailCount++
|
||||
cache.SetStr(failCountKey, strconv.Itoa(nowFailCount), time.Minute*time.Duration(loginFailMin))
|
||||
panic(biz.NewBizErr(fmt.Sprintf("用户名或密码错误【当前登录失败%d次】", nowFailCount)))
|
||||
}
|
||||
|
||||
rc.ResData = LastLoginCheck(account, accountLoginSecurity, clientIp)
|
||||
}
|
||||
|
||||
func (a *LdapLogin) getUser(userName string, cols ...string) (*sysentity.Account, error) {
|
||||
account := &sysentity.Account{Username: userName}
|
||||
if err := a.AccountApp.GetAccount(account, cols...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (a *LdapLogin) createUser(userName, displayName string) {
|
||||
account := &sysentity.Account{Username: userName}
|
||||
account.SetBaseInfo(nil)
|
||||
account.Name = displayName
|
||||
a.AccountApp.Create(account)
|
||||
// 将 LADP 用户本地密码设置为空,不允许本地登录
|
||||
account.Password = cryptox.PwdHash("")
|
||||
a.AccountApp.Update(account)
|
||||
}
|
||||
|
||||
func (a *LdapLogin) getOrCreateUserWithLdap(userName string, password string, cols ...string) (*sysentity.Account, error) {
|
||||
userInfo, err := ldap.Authenticate(userName, password)
|
||||
if err != nil {
|
||||
return nil, errors.New("用户名密码错误")
|
||||
}
|
||||
|
||||
account, err := a.getUser(userName, cols...)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
a.createUser(userName, userInfo.DisplayName)
|
||||
return a.getUser(userName, cols...)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
@@ -17,6 +17,12 @@ func Init(router *gin.RouterGroup) {
|
||||
MsgApp: msgapp.GetMsgApp(),
|
||||
}
|
||||
|
||||
ldapLogin := &api.LdapLogin{
|
||||
ConfigApp: sysapp.GetConfigApp(),
|
||||
AccountApp: sysapp.GetAccountApp(),
|
||||
MsgApp: msgapp.GetMsgApp(),
|
||||
}
|
||||
|
||||
oauth2Login := &api.Oauth2Login{
|
||||
Oauth2App: application.GetAuthApp(),
|
||||
ConfigApp: sysapp.GetConfigApp(),
|
||||
@@ -50,6 +56,10 @@ func Init(router *gin.RouterGroup) {
|
||||
req.NewGet("/oauth2/status", oauth2Login.Oauth2Status),
|
||||
|
||||
req.NewGet("/oauth2/unbind", oauth2Login.Oauth2Unbind).Log(req.NewLogSave("oauth2解绑")),
|
||||
|
||||
// LDAP 登录
|
||||
req.NewGet("/ldap/enabled", ldapLogin.GetLdapEnabled).DontNeedToken(),
|
||||
req.NewPost("/ldap/login", ldapLogin.Login).Log(req.NewLogSave("LDAP 登录")).DontNeedToken(),
|
||||
}
|
||||
|
||||
req.BatchSetGroup(rg, reqs[:])
|
||||
|
||||
@@ -43,6 +43,7 @@ type Config struct {
|
||||
Mysql *Mysql `yaml:"mysql"`
|
||||
Redis *Redis `yaml:"redis"`
|
||||
Log *Log `yaml:"log"`
|
||||
Ldap *Ldap `yaml:"ldap"`
|
||||
}
|
||||
|
||||
// 配置文件内容校验
|
||||
|
||||
45
server/pkg/config/ldap.go
Normal file
45
server/pkg/config/ldap.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
// FieldMapping 表示用户属性和 LDAP 字段名之间的映射关系
|
||||
type FieldMapping struct {
|
||||
// Identifier 表示用户标识
|
||||
Identifier string `yaml:"identifier,omitempty"`
|
||||
// DisplayName 表示用户姓名
|
||||
DisplayName string `yaml:"displayName,omitempty"`
|
||||
// Email 表示 Email 地址
|
||||
Email string `yaml:"email,omitempty"`
|
||||
}
|
||||
|
||||
// SecurityProtocol 表示连接 LDAP 服务器的安全协议
|
||||
type SecurityProtocol string
|
||||
|
||||
const (
|
||||
// SecurityProtocolStartTLS 表示 StartTLS 安全协议
|
||||
SecurityProtocolStartTLS SecurityProtocol = "starttls"
|
||||
// SecurityProtocolLDAPS 表示 LDAPS 安全协议
|
||||
SecurityProtocolLDAPS SecurityProtocol = "ldaps"
|
||||
)
|
||||
|
||||
// Ldap 是 LDAP 服务配置
|
||||
type Ldap struct {
|
||||
// Enabled 表示是否启用 LDAP 登录
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// Host 是 LDAP 服务地址, 如: "ldap.example.com"
|
||||
Host string `yaml:"host"`
|
||||
// Port 是 LDAP 服务端口号, 如: 389
|
||||
Port int `yaml:"port"`
|
||||
// SkipTLSVerify 控制客户端是否跳过 TLS 证书验证
|
||||
SkipTLSVerify bool `yaml:"skipTlsVerify"`
|
||||
// BindDN 是 LDAP 服务的管理员账号,如: "cn=admin,dc=example,dc=com"
|
||||
BindDN string `yaml:"bindDn"`
|
||||
// BindPassword 是 LDAP 服务的管理员密码
|
||||
BindPassword string `yaml:"bindPassword"`
|
||||
// BaseDN 是用户所在的 base DN, 如: "ou=users,dc=example,dc=com".
|
||||
BaseDN string `yaml:"baseDn"`
|
||||
// UserFilter 是过滤用户的方式, 如: "(uid=%s)".
|
||||
UserFilter string `yaml:"userFilter"`
|
||||
// SecurityProtocol 是连接使用的 LDAP 安全协议(为空不使用安全协议),如: StartTLS, LDAPS
|
||||
SecurityProtocol SecurityProtocol `yaml:"securityProtocol"`
|
||||
// FieldMapping 表示用户属性和 LDAP 字段名之间的映射关系
|
||||
FieldMapping FieldMapping `yaml:"fieldMapping"`
|
||||
}
|
||||
107
server/pkg/ldap/ldap.go
Normal file
107
server/pkg/ldap/ldap.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/pkg/errors"
|
||||
"mayfly-go/pkg/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UserInfo struct {
|
||||
UserName string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
|
||||
func dial() (*ldap.Conn, error) {
|
||||
conf := config.Conf.Ldap
|
||||
addr := fmt.Sprintf("%s:%d", conf.Host, conf.Port)
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: conf.Host,
|
||||
InsecureSkipVerify: conf.SkipTLSVerify,
|
||||
}
|
||||
if conf.SecurityProtocol == config.SecurityProtocolLDAPS {
|
||||
conn, err := ldap.DialTLS("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("dial TLS: %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
conn, err := ldap.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("dial: %v", err)
|
||||
}
|
||||
if conf.SecurityProtocol == config.SecurityProtocolStartTLS {
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, errors.Errorf("start TLS: %v", err)
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Connect 创建 LDAP 连接
|
||||
func Connect() (*ldap.Conn, error) {
|
||||
conn, err := dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bind with a system account
|
||||
conf := config.Conf.Ldap
|
||||
err = conn.Bind(conf.BindDN, conf.BindPassword)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, errors.Errorf("bind: %v", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Authenticate 通过 LDAP 验证用户名密码
|
||||
func Authenticate(username, password string) (*UserInfo, error) {
|
||||
conn, err := Connect()
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("connect: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
conf := config.Conf.Ldap
|
||||
sr, err := conn.Search(
|
||||
ldap.NewSearchRequest(
|
||||
conf.BaseDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
strings.ReplaceAll(conf.UserFilter, "%s", username),
|
||||
[]string{"dn", conf.FieldMapping.Identifier, conf.FieldMapping.DisplayName, conf.FieldMapping.Email},
|
||||
nil,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("search user DN: %v", err)
|
||||
} else if len(sr.Entries) != 1 {
|
||||
return nil, errors.Errorf("expect 1 user DN but got %d", len(sr.Entries))
|
||||
}
|
||||
entry := sr.Entries[0]
|
||||
|
||||
// Bind as the user to verify their password
|
||||
err = conn.Bind(entry.DN, password)
|
||||
if err != nil {
|
||||
return nil, errors.Errorf("bind user: %v", err)
|
||||
}
|
||||
|
||||
userName := entry.GetAttributeValue(conf.FieldMapping.Identifier)
|
||||
if userName == "" {
|
||||
return nil, errors.Errorf("the attribute %q is not found or has empty value", conf.FieldMapping.Identifier)
|
||||
}
|
||||
return &UserInfo{
|
||||
UserName: userName,
|
||||
DisplayName: entry.GetAttributeValue(conf.FieldMapping.DisplayName),
|
||||
Email: entry.GetAttributeValue(conf.FieldMapping.Email),
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user