update password when first login & add password period

This commit is contained in:
zhenorzz 2024-01-12 15:35:23 +08:00
parent 1e1f5f883b
commit d86ddd0e9d
16 changed files with 210 additions and 83 deletions

View File

@ -20,6 +20,7 @@ import (
"github.com/zhenorzz/goploy/internal/model"
"github.com/zhenorzz/goploy/internal/server"
"github.com/zhenorzz/goploy/internal/server/response"
"github.com/zhenorzz/goploy/internal/validator"
"net/http"
"strconv"
"strings"
@ -56,9 +57,10 @@ func (u User) Handler() []server.Route {
// @Router /user/login [post]
func (User) Login(gp *server.Goploy) server.Response {
type ReqData struct {
Account string `json:"account" validate:"required,min=1,max=25"`
Password string `json:"password" validate:"required,password"`
CaptchaKey string `json:"captchaKey" validate:"omitempty"`
Account string `json:"account" validate:"required,min=1,max=25"`
Password string `json:"password" validate:"required,password"`
NewPassword string `json:"newPassword"`
CaptchaKey string `json:"captchaKey" validate:"omitempty"`
}
var reqData ReqData
if err := gp.Decode(&reqData); err != nil {
@ -138,23 +140,23 @@ func (User) Login(gp *server.Goploy) server.Response {
} else {
if userData.ID == 0 {
return response.JSON{Code: response.Error, Message: "We couldn't verify your identity. Please confirm if your username and password are correct."}
return response.JSON{Code: response.Error, Message: "We couldn't verify your identity. Please confirm if your username and password are correct"}
}
if err := userData.Validate(reqData.Password); err != nil {
errorTimes := userCache.IncErrorTimes(reqData.Account, cache.UserCacheExpireTime, cache.UserCacheShowCaptchaTime)
// error times over 5 times, then lock the account 15 minutes
if errorTimes >= cache.UserCacheMaxErrorTimes {
if userCache.IncrErrorTimes(reqData.Account, cache.UserCacheExpireTime) >= cache.UserCacheMaxErrorTimes {
userCache.LockAccount(reqData.Account, cache.UserCacheLockTime)
}
return response.JSON{Code: response.Deny, Message: err.Error()}
}
}
userCache.DeleteShowCaptcha(reqData.Account)
userCache.DeleteErrorTimes(reqData.Account)
if userData.State == model.Disable {
return response.JSON{Code: response.AccountDisabled, Message: "Account is disabled"}
}
namespaceList, err := model.Namespace{UserID: userData.ID}.GetAllByUserID()
if err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
@ -162,6 +164,29 @@ func (User) Login(gp *server.Goploy) server.Response {
return response.JSON{Code: response.Error, Message: "No space assigned, please contact the administrator"}
}
if reqData.NewPassword != "" {
if err := validator.Validate.Var(reqData.NewPassword, "password"); err != nil {
return response.JSON{Code: response.PasswordExpired, Message: err.Error()}
}
if reqData.Password == reqData.NewPassword {
return response.JSON{Code: response.PasswordExpired, Message: "The password cannot be the same as the previous one"}
}
if err := (model.User{ID: userData.ID, Password: reqData.NewPassword, PasswordUpdateTime: sql.NullString{
String: time.Now().Format("20060102150405"),
Valid: true,
}}).UpdatePassword(); err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
}
} else if !userData.PasswordUpdateTime.Valid {
return response.JSON{Code: response.PasswordExpired, Message: "You need to change your password upon first login"}
} else if config.Toml.APP.PasswordPeriod > 0 {
passwordUpdateTime, _ := time.Parse(time.DateTime, userData.PasswordUpdateTime.String)
passwordUpdateTime = passwordUpdateTime.AddDate(0, 0, config.Toml.APP.PasswordPeriod)
if passwordUpdateTime.Before(time.Now()) {
return response.JSON{Code: response.PasswordExpired, Message: "Password expired, please change"}
}
}
token, err := userData.CreateToken()
if err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
@ -482,7 +507,14 @@ func (User) ChangePassword(gp *server.Goploy) server.Response {
return response.JSON{Code: response.Error, Message: err.Error()}
}
if err := (model.User{ID: gp.UserInfo.ID, Password: reqData.NewPassword}).UpdatePassword(); err != nil {
if reqData.OldPassword == reqData.NewPassword {
return response.JSON{Code: response.Error, Message: "The password cannot be the same as the previous one."}
}
if err := (model.User{ID: gp.UserInfo.ID, Password: reqData.NewPassword, PasswordUpdateTime: sql.NullString{
String: time.Now().Format("20060102150405"),
Valid: true,
}}).UpdatePassword(); err != nil {
return response.JSON{Code: response.Error, Message: err.Error()}
}
return response.JSON{}

View File

@ -37,6 +37,7 @@ type APPConfig struct {
DeployLimit int32 `toml:"deployLimit"`
ShutdownTimeout time.Duration `toml:"shutdownTimeout"`
RepositoryPath string `toml:"repositoryPath"`
PasswordPeriod int `toml:"passwordPeriod"`
}
type CORSConfig struct {

2
database/1.16.2.sql Normal file
View File

@ -0,0 +1,2 @@
alter table user
add password_update_time datetime null after password;

View File

@ -247,6 +247,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`account` varchar(30) NOT NULL DEFAULT '',
`password` varchar(60) NOT NULL DEFAULT '',
`password_update_time` datetime DEFAULT NULL,
`name` varchar(30) NOT NULL DEFAULT '',
`contact` varchar(255) NOT NULL DEFAULT '',
`state` tinyint(1) NOT NULL DEFAULT '1' COMMENT '0.disable 1.enable',

View File

@ -3,6 +3,8 @@ env = 'production'
deployLimit = 8
shutdownTimeout = 10
repositoryPath = ''
# How many days before the password needs to be changed?
passwordPeriod = 0
[cors]
enabled = false

View File

@ -6,9 +6,8 @@ import (
)
const (
UserCacheKey = "login_error_times_"
UserCacheLockKey = "login_lock_"
UserCacheShowCaptchaKey = "login_show_captcha_"
UserCacheKey = "login_error_times_"
UserCacheLockKey = "login_lock_"
)
type UserCache struct {
@ -18,6 +17,7 @@ type UserCache struct {
type user struct {
times int
data any
expireIn time.Time
}
@ -25,7 +25,7 @@ var userCache = &UserCache{
data: make(map[string]user),
}
func (uc *UserCache) IncErrorTimes(account string, expireTime time.Duration, showCaptchaTime time.Duration) int {
func (uc *UserCache) IncrErrorTimes(account string, expireTime time.Duration) int {
uc.Lock()
defer uc.Unlock()
@ -47,16 +47,6 @@ func (uc *UserCache) IncErrorTimes(account string, expireTime time.Duration, sho
delete(uc.data, cacheKey)
})
// show captcha
showCaptchaKey := getShowCaptchaKey(account)
uc.data[showCaptchaKey] = user{
times: 1,
expireIn: time.Now().Add(showCaptchaTime),
}
time.AfterFunc(showCaptchaTime, func() {
delete(uc.data, showCaptchaKey)
})
return times
}
@ -97,17 +87,16 @@ func (uc *UserCache) IsShowCaptcha(account string) bool {
uc.RLock()
defer uc.RUnlock()
showCaptchaKey := getShowCaptchaKey(account)
v, ok := uc.data[showCaptchaKey]
v, ok := uc.data[getCacheKey(account)]
return ok && !v.expireIn.IsZero() && v.expireIn.After(time.Now()) && v.times > 0
}
func (uc *UserCache) DeleteShowCaptcha(account string) {
func (uc *UserCache) DeleteErrorTimes(account string) {
uc.Lock()
defer uc.Unlock()
delete(uc.data, getShowCaptchaKey(account))
delete(uc.data, getCacheKey(account))
}
func getCacheKey(account string) string {
@ -118,10 +107,6 @@ func getLockKey(account string) string {
return UserCacheLockKey + account
}
func getShowCaptchaKey(account string) string {
return UserCacheShowCaptchaKey + account
}
func GetUserCache() *UserCache {
return userCache
}

View File

@ -3,16 +3,15 @@ package cache
import "time"
type User interface {
IncErrorTimes(account string, expireTime time.Duration, showCaptchaTime time.Duration) int
IncrErrorTimes(account string, expireTime time.Duration) int
LockAccount(account string, lockTime time.Duration)
IsLock(account string) bool
IsShowCaptcha(account string) bool
DeleteShowCaptcha(account string)
DeleteErrorTimes(account string)
}
const (
UserCacheMaxErrorTimes = 5
UserCacheExpireTime = 5 * time.Minute
UserCacheLockTime = 15 * time.Minute
UserCacheShowCaptchaTime = 15 * time.Minute
UserCacheMaxErrorTimes = 5
UserCacheExpireTime = 5 * time.Minute
UserCacheLockTime = 15 * time.Minute
)

View File

@ -6,6 +6,7 @@ package model
import (
"crypto/rand"
"database/sql"
"errors"
"github.com/zhenorzz/goploy/config"
"time"
@ -24,17 +25,18 @@ const (
)
type User struct {
ID int64 `json:"id"`
Account string `json:"account"`
Password string `json:"password"`
Name string `json:"name"`
Contact string `json:"contact"`
SuperManager int64 `json:"superManager"`
State uint8 `json:"state"`
InsertTime string `json:"insertTime"`
UpdateTime string `json:"updateTime"`
LastLoginTime string `json:"lastLoginTime"`
ApiKey string `json:"apiKey"`
ID int64 `json:"id"`
Account string `json:"account"`
Password string `json:"password"`
PasswordUpdateTime sql.NullString `json:"passwordUpdateTime"`
Name string `json:"name"`
Contact string `json:"contact"`
SuperManager int64 `json:"superManager"`
State uint8 `json:"state"`
InsertTime string `json:"insertTime"`
UpdateTime string `json:"updateTime"`
LastLoginTime string `json:"lastLoginTime"`
ApiKey string `json:"apiKey"`
}
type Users []User
@ -57,12 +59,12 @@ func (u User) GetData() (User, error) {
func (u User) GetDataByAccount() (User, error) {
var user User
err := sq.
Select("id, account, password, name, contact, super_manager, state, insert_time, update_time").
Select("id, account, password, password_update_time, name, contact, super_manager, state, insert_time, update_time").
From(userTable).
Where(sq.Eq{"account": u.Account}).
RunWith(DB).
QueryRow().
Scan(&user.ID, &user.Account, &user.Password, &user.Name, &user.Contact, &user.SuperManager, &user.State, &user.InsertTime, &user.UpdateTime)
Scan(&user.ID, &user.Account, &user.Password, &user.PasswordUpdateTime, &user.Name, &user.Contact, &user.SuperManager, &user.State, &user.InsertTime, &user.UpdateTime)
if err != nil {
return user, err
}
@ -221,7 +223,8 @@ func (u User) UpdatePassword() error {
_, err = sq.
Update(userTable).
SetMap(sq.Eq{
"password": string(hashedPassword),
"password": string(hashedPassword),
"password_update_time": u.PasswordUpdateTime,
}).
Where(sq.Eq{"id": u.ID}).
RunWith(DB).

View File

@ -31,6 +31,7 @@ const (
IllegalRequest = 10001
NamespaceInvalid = 10002
IllegalParam = 10003
PasswordExpired = 10004
LoginExpired = 10086
)

View File

@ -6,6 +6,7 @@ package server
import (
"database/sql"
"errors"
"fmt"
"github.com/golang-jwt/jwt"
log "github.com/sirupsen/logrus"
@ -157,7 +158,7 @@ func (rt *Router) doRequest(w http.ResponseWriter, r *http.Request) (*Goploy, Re
UserID: gp.UserInfo.ID,
}.GetDataByUserNamespace()
if err != nil {
if err == sql.ErrNoRows {
if errors.Is(err, sql.ErrNoRows) {
return gp, response.JSON{Code: response.NamespaceInvalid, Message: "No available namespace"}
} else {
return gp, response.JSON{Code: response.Deny, Message: err.Error()}

View File

@ -19,6 +19,7 @@ export class Login extends Request {
public param: {
account: string
password: string
newPassword: string
captchaKey: string
}
public declare datagram: {

View File

@ -1,6 +1,7 @@
{
"default": "Default",
"name": "Name",
"signIn": "Sign in",
"tag": "Tag",
"script": "Script",
"owner": "Owner",

View File

@ -1,6 +1,7 @@
{
"default": "默认",
"name": "名称",
"signIn": "登录",
"tag": "标签",
"script": "脚本",
"owner": "拥有者",

View File

@ -31,11 +31,12 @@ const mutations: MutationTree<UserState> = {
const actions: ActionTree<UserState, RootState> = {
// user login
login(_, userInfo) {
const { account, password, captchaKey } = userInfo
const { account, password, newPassword, captchaKey } = userInfo
return new Promise((resolve, reject) => {
new Login({
account: account.trim(),
password: password,
newPassword: newPassword,
captchaKey: captchaKey,
})
.request()

View File

@ -5,7 +5,7 @@
</el-row>
<el-form
ref="form"
:model="loginForm"
:model="loginFormData"
:rules="loginRules"
class="login-form"
auto-complete="on"
@ -13,7 +13,7 @@
>
<div class="title-container">
<h3 class="title">
Sign in to Goploy <sub>{{ version }}</sub>
Goploy <sub>{{ version }}</sub>
</h3>
</div>
@ -22,8 +22,8 @@
<svg-icon icon-class="user" />
</span>
<input
v-model="loginForm.account"
placeholder="account"
v-model="loginFormData.account"
:placeholder="$t('account')"
name="account"
type="text"
tabindex="1"
@ -36,18 +36,69 @@
<svg-icon icon-class="password" />
</span>
<input
:key="passwordType"
v-model="loginForm.password"
:type="passwordType"
placeholder="password"
v-model="loginFormData.password"
:type="loginFormProps.type.password"
:placeholder="$t('password')"
name="password"
tabindex="2"
auto-complete="on"
@keyup.enter="handleLogin"
/>
<span class="show-pwd" @click="showPwd">
<span class="show-pwd" @click="showPwd(inputElem.password)">
<svg-icon
:icon-class="passwordType === 'password' ? 'eye' : 'eye-open'"
:icon-class="
loginFormProps.type[inputElem.password] === 'password'
? 'eye'
: 'eye-open'
"
/>
</span>
</el-form-item>
<el-form-item
v-if="loginFormProps.showEditPassword"
prop="newPassword"
class="login-form-input"
>
<span class="svg-container">
<svg-icon icon-class="password" />
</span>
<input
v-model="loginFormData.newPassword"
:type="loginFormProps.type.newPassword"
:placeholder="$t('userPage.newPassword')"
/>
<span class="show-pwd" @click="showPwd(inputElem.newPassword)">
<svg-icon
:icon-class="
loginFormProps.type[inputElem.newPassword] === 'password'
? 'eye'
: 'eye-open'
"
/>
</span>
</el-form-item>
<el-form-item
v-if="loginFormProps.showEditPassword"
prop="confirmPassword"
class="login-form-input"
>
<span class="svg-container">
<svg-icon icon-class="password" />
</span>
<input
v-model="loginFormData.confirmPassword"
:type="loginFormProps.type.confirmPassword"
:placeholder="$t('userPage.rePassword')"
/>
<span class="show-pwd" @click="showPwd(inputElem.confirmPassword)">
<svg-icon
:icon-class="
loginFormProps.type[inputElem.confirmPassword] === 'password'
? 'eye'
: 'eye-open'
"
/>
</span>
</el-form-item>
@ -72,7 +123,7 @@
style="width: 100%; margin-bottom: 30px"
@click.prevent="handleLogin"
>
Sign in
{{ $t('signIn') }}
</el-button>
<el-divider v-if="Object.keys(mediaLoginUrl).length > 0" class="divider">
<span class="media-logo">
@ -102,16 +153,34 @@ import { GetCaptcha, CheckCaptcha, GetConfig } from '@/api/user'
import GoCaptchaBtn from './components/GoCaptchaBtn.vue'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n({ useScope: 'global' })
enum inputElem {
password = 'password',
newPassword = 'newPassword',
confirmPassword = 'confirmPassword',
}
const version = import.meta.env.VITE_APP_VERSION
const store = useStore()
const router = useRouter()
const form = ref<InstanceType<typeof ElForm>>()
const loginForm = ref({
const loginFormData = ref({
account: import.meta.env.PROD === true ? '' : 'admin',
password: import.meta.env.PROD === true ? '' : 'admin!@#',
newPassword: '',
confirmPassword: '',
phrase: '',
captchaKey: '',
})
const loginFormProps = ref({
loading: false,
showEditPassword: false,
type: {
password: 'password',
newPassword: 'password',
confirmPassword: 'password',
},
})
const loginRules: InstanceType<typeof ElForm>['rules'] = {
account: [
{
@ -141,10 +210,39 @@ const loginRules: InstanceType<typeof ElForm>['rules'] = {
},
},
],
newPassword: [
{
required: true,
trigger: ['blur'],
validator: (_, value) => {
if (!validPassword(value)) {
return new Error(
'8 to 16 characters and a minimum of 2 character sets from these classes: [letters], [numbers], [special characters]'
)
} else {
return true
}
},
},
],
confirmPassword: [
{
required: true,
trigger: ['blur'],
validator: (_, value) => {
if (value === '') {
return new Error('Please enter the password again')
} else if (value !== loginFormData.value.newPassword) {
return new Error('The two passwords do not match!')
} else {
return true
}
},
},
],
}
const redirectUri = window.location.origin + '/#/login'
const passwordType = ref('password')
const loading = ref(false)
const redirect = ref()
const query = ref()
@ -204,9 +302,10 @@ function getConfig() {
captchaEnabled.value = response.data.captcha.enabled
ldapEnabled.value = response.data.ldap.enabled
for (const media in response.data['mediaURL']) {
if (response.data['mediaURL'][media] != '') {
mediaLoginUrl.value[media] = `${
response.data['mediaURL'][media]
const key = media as keyof typeof response.data['mediaURL']
if (response.data['mediaURL'][key] != '') {
mediaLoginUrl.value[key] = `${
response.data['mediaURL'][key]
}&redirect_uri=${encodeURIComponent(redirectUri)}`
}
}
@ -222,7 +321,7 @@ function handleRequestCaptCode() {
captchaBase64.value = response.data.base64
captchaThumbBase64.value = response.data.thumbBase64
captchaKey.value = response.data.key
loginForm.value.captchaKey = response.data.key
loginFormData.value.captchaKey = response.data.key
})
}
@ -256,16 +355,12 @@ function handleConfirm(dots: { x: number; y: number; index: number }[]) {
})
}
const password = ref<HTMLInputElement>()
function showPwd() {
if (passwordType.value === 'password') {
passwordType.value = ''
function showPwd(index: inputElem) {
if (loginFormProps.value.type[index] === 'password') {
loginFormProps.value.type[index] = ''
} else {
passwordType.value = 'password'
loginFormProps.value.type[index] = 'password'
}
nextTick(() => {
password.value?.focus()
})
}
function handleExtLogin(account: string, time: number, token: string) {
@ -288,7 +383,7 @@ function handleLogin() {
if (valid) {
loading.value = true
store
.dispatch('user/login', loginForm.value)
.dispatch('user/login', loginFormData.value)
.then(() => {
router.push({
path: redirect.value || '/',
@ -296,11 +391,13 @@ function handleLogin() {
})
loading.value = false
})
.catch(() => {
if (captchaEnabled.value) {
.catch((error) => {
if (error.data.code == 10004) {
loginFormProps.value.showEditPassword = true
} else if (captchaEnabled.value) {
captchaShow.value = true
captchaStatus.value = 'default'
loginForm.value.captchaKey = ''
loginFormData.value.captchaKey = ''
}
loading.value = false
})
@ -364,6 +461,7 @@ $cursor: #2f2f2f;
}
.el-form-item__error {
padding-top: 4px;
z-index: 1;
}
.el-form-item {
background: #fff;

View File

@ -144,8 +144,6 @@ const formRules: InstanceType<typeof ElForm>['rules'] = {
new: [
{
required: true,
message:
'8 to 16 characters and a minimum of 2 character sets from these classes: [letters], [numbers], [special characters]',
trigger: ['blur'],
validator: (_, value) => {
if (!validPassword(value)) {