feat: 实现 LDAP 登录

This commit is contained in:
kanzihuang
2023-08-23 22:09:41 +08:00
committed by Wanli
parent 2e969d46fb
commit 4e1350d1cc
13 changed files with 321 additions and 5174 deletions

2
.gitignore vendored
View File

@@ -23,4 +23,4 @@ out
server/docs/docker-compose
server/config.yml
mayfly-go.log
mayfly-go.log

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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);
}

View File

@@ -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 = '';

View File

@@ -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;

View File

@@ -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"

View File

@@ -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 {

View 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
}

View File

@@ -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[:])

View File

@@ -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
View 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
View 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
}