all: session storage usage
This commit is contained in:
@@ -355,8 +355,12 @@ func (ds *DefaultSessionStorage) store(s *Session) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindByToken implements the [SessionStorage] interface for *DefaultSessionStorage.
|
// FindByToken implements the [SessionStorage] interface for
|
||||||
func (ds *DefaultSessionStorage) FindByToken(ctx context.Context, t SessionToken) (s *Session, err error) {
|
// *DefaultSessionStorage.
|
||||||
|
func (ds *DefaultSessionStorage) FindByToken(
|
||||||
|
ctx context.Context,
|
||||||
|
t SessionToken,
|
||||||
|
) (s *Session, err error) {
|
||||||
ds.mu.Lock()
|
ds.mu.Lock()
|
||||||
defer ds.mu.Unlock()
|
defer ds.mu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -1,317 +1,131 @@
|
|||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
|
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||||
"github.com/AdguardTeam/golibs/errors"
|
"github.com/AdguardTeam/golibs/errors"
|
||||||
"github.com/AdguardTeam/golibs/log"
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
"github.com/AdguardTeam/golibs/netutil"
|
"github.com/AdguardTeam/golibs/netutil"
|
||||||
"go.etcd.io/bbolt"
|
"github.com/AdguardTeam/golibs/timeutil"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionTokenSize is the length of session token in bytes.
|
// webUser represents a user of the Web UI.
|
||||||
const sessionTokenSize = 16
|
type webUser struct {
|
||||||
|
// Name represents the login name of the web user.
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
|
||||||
type session struct {
|
// PasswordHash is the hashed representation of the web user password.
|
||||||
userName string
|
PasswordHash string `yaml:"password"`
|
||||||
// expire is the expiration time, in seconds.
|
|
||||||
expire uint32
|
// UserID is the unique identifier of the web user.
|
||||||
|
//
|
||||||
|
// TODO(s.chzhen): !! Use this.
|
||||||
|
UserID aghuser.UserID `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) serialize() []byte {
|
// toUser returns the new properly initialized *aghuser.User using stored
|
||||||
const (
|
// properties. It panics if there is an error generating the user ID.
|
||||||
expireLen = 4
|
func (wu *webUser) toUser() (u *aghuser.User) {
|
||||||
nameLen = 2
|
uid := wu.UserID
|
||||||
)
|
if uid == (aghuser.UserID{}) {
|
||||||
data := make([]byte, expireLen+nameLen+len(s.userName))
|
uid = aghuser.MustNewUserID()
|
||||||
binary.BigEndian.PutUint32(data[0:4], s.expire)
|
|
||||||
binary.BigEndian.PutUint16(data[4:6], uint16(len(s.userName)))
|
|
||||||
copy(data[6:], []byte(s.userName))
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *session) deserialize(data []byte) bool {
|
|
||||||
if len(data) < 4+2 {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
s.expire = binary.BigEndian.Uint32(data[0:4])
|
|
||||||
nameLen := binary.BigEndian.Uint16(data[4:6])
|
|
||||||
data = data[6:]
|
|
||||||
|
|
||||||
if len(data) < int(nameLen) {
|
return &aghuser.User{
|
||||||
return false
|
Password: aghuser.NewDefaultPassword(wu.PasswordHash),
|
||||||
|
Login: aghuser.Login(wu.Name),
|
||||||
|
ID: uid,
|
||||||
}
|
}
|
||||||
s.userName = string(data)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth is the global authentication object.
|
// Auth is the global authentication object.
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
trustedProxies netutil.SubnetSet
|
logger *slog.Logger
|
||||||
db *bbolt.DB
|
|
||||||
rateLimiter *authRateLimiter
|
rateLimiter *authRateLimiter
|
||||||
sessions map[string]*session
|
sessions aghuser.SessionStorage
|
||||||
users []webUser
|
trustedProxies netutil.SubnetSet
|
||||||
lock sync.Mutex
|
users aghuser.DB
|
||||||
sessionTTL uint32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// webUser represents a user of the Web UI.
|
// InitAuth initializes the global authentication object. baseLogger,
|
||||||
//
|
// rateLimiter, trustedProxies must not be nil. dbFilename and sessionTTL
|
||||||
// TODO(s.chzhen): Improve naming.
|
// should not be empty.
|
||||||
type webUser struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
PasswordHash string `yaml:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitAuth initializes the global authentication object.
|
|
||||||
func InitAuth(
|
func InitAuth(
|
||||||
|
ctx context.Context,
|
||||||
|
baseLogger *slog.Logger,
|
||||||
dbFilename string,
|
dbFilename string,
|
||||||
users []webUser,
|
users []webUser,
|
||||||
sessionTTL uint32,
|
sessionTTL time.Duration,
|
||||||
rateLimiter *authRateLimiter,
|
rateLimiter *authRateLimiter,
|
||||||
trustedProxies netutil.SubnetSet,
|
trustedProxies netutil.SubnetSet,
|
||||||
) (a *Auth) {
|
) (a *Auth, err error) {
|
||||||
log.Info("Initializing auth module: %s", dbFilename)
|
userDB := aghuser.NewDefaultDB()
|
||||||
|
for i, u := range users {
|
||||||
a = &Auth{
|
err = userDB.Create(ctx, u.toUser())
|
||||||
sessionTTL: sessionTTL,
|
if err != nil {
|
||||||
rateLimiter: rateLimiter,
|
return nil, fmt.Errorf("users: at index %d: %w", i, err)
|
||||||
sessions: make(map[string]*session),
|
|
||||||
users: users,
|
|
||||||
trustedProxies: trustedProxies,
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
|
|
||||||
a.db, err = bbolt.Open(dbFilename, aghos.DefaultPermFile, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: open DB: %s: %s", dbFilename, err)
|
|
||||||
if err.Error() == "invalid argument" {
|
|
||||||
log.Error("AdGuard Home cannot be initialized due to an incompatible file system.\nPlease read the explanation here: https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#limitations")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
a.loadSessions()
|
|
||||||
log.Info("auth: initialized. users:%d sessions:%d", len(a.users), len(a.sessions))
|
|
||||||
|
|
||||||
return a
|
s, err := aghuser.NewDefaultSessionStorage(ctx, &aghuser.DefaultSessionStorageConfig{
|
||||||
|
Logger: baseLogger.With(slogutil.KeyPrefix, "session_storage"),
|
||||||
|
Clock: timeutil.SystemClock{},
|
||||||
|
UserDB: aghuser.NewDefaultDB(),
|
||||||
|
DBPath: dbFilename,
|
||||||
|
SessionTTL: sessionTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating session storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Auth{
|
||||||
|
logger: baseLogger.With(slogutil.KeyPrefix, "auth"),
|
||||||
|
rateLimiter: rateLimiter,
|
||||||
|
trustedProxies: trustedProxies,
|
||||||
|
sessions: s,
|
||||||
|
users: userDB,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the authentication database.
|
// Close closes the authentication database.
|
||||||
func (a *Auth) Close() {
|
func (a *Auth) Close(ctx context.Context) {
|
||||||
_ = a.db.Close()
|
err := a.sessions.Close()
|
||||||
}
|
|
||||||
|
|
||||||
func bucketName() []byte {
|
|
||||||
return []byte("sessions-2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadSessions loads sessions from the database file and removes expired
|
|
||||||
// sessions.
|
|
||||||
func (a *Auth) loadSessions() {
|
|
||||||
tx, err := a.db.Begin(true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("auth: bbolt.Begin: %s", err)
|
a.logger.ErrorContext(ctx, "closing session storage", slogutil.KeyError, err)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bkt := tx.Bucket(bucketName())
|
|
||||||
if bkt == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
removed := 0
|
|
||||||
|
|
||||||
if tx.Bucket([]byte("sessions")) != nil {
|
|
||||||
_ = tx.DeleteBucket([]byte("sessions"))
|
|
||||||
removed = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
now := uint32(time.Now().UTC().Unix())
|
|
||||||
forEach := func(k, v []byte) error {
|
|
||||||
s := session{}
|
|
||||||
if !s.deserialize(v) || s.expire <= now {
|
|
||||||
err = bkt.Delete(k)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Delete: %s", err)
|
|
||||||
} else {
|
|
||||||
removed++
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
a.sessions[hex.EncodeToString(k)] = &s
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_ = bkt.ForEach(forEach)
|
|
||||||
if removed != 0 {
|
|
||||||
err = tx.Commit()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("bolt.Commit(): %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("auth: loaded %d sessions from DB (removed %d expired)", len(a.sessions), removed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSession adds a new session to the list of sessions and saves it in the
|
|
||||||
// database file.
|
|
||||||
func (a *Auth) addSession(data []byte, s *session) {
|
|
||||||
name := hex.EncodeToString(data)
|
|
||||||
a.lock.Lock()
|
|
||||||
a.sessions[name] = s
|
|
||||||
a.lock.Unlock()
|
|
||||||
if a.storeSession(data, s) {
|
|
||||||
log.Debug("auth: created session %s: expire=%d", name, s.expire)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeSession saves a session in the database file.
|
// isValidSession returns true if the session is valid.
|
||||||
func (a *Auth) storeSession(data []byte, s *session) bool {
|
func (a *Auth) isValidSession(ctx context.Context, cookieSess string) (ok bool) {
|
||||||
tx, err := a.db.Begin(true)
|
sess, err := hex.DecodeString(cookieSess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("auth: bbolt.Begin: %s", err)
|
a.logger.ErrorContext(ctx, "checking session: decoding cookie", slogutil.KeyError, err)
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bkt, err := tx.CreateBucketIfNotExists(bucketName())
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.CreateBucketIfNotExists: %s", err)
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err = bkt.Put(data, s.serialize())
|
var t aghuser.SessionToken
|
||||||
|
copy(t[:], sess)
|
||||||
|
|
||||||
|
s, err := a.sessions.FindByToken(ctx, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("auth: bbolt.Put: %s", err)
|
a.logger.ErrorContext(ctx, "checking session", slogutil.KeyError, err)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
return s != nil
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Commit: %s", err)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeSessionFromFile removes a stored session from the DB file on disk.
|
// addUser adds a new user with the given password. u must not be nil.
|
||||||
func (a *Auth) removeSessionFromFile(sess []byte) {
|
func (a *Auth) addUser(ctx context.Context, u *webUser, password string) (err error) {
|
||||||
tx, err := a.db.Begin(true)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Begin: %s", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
bkt := tx.Bucket(bucketName())
|
|
||||||
if bkt == nil {
|
|
||||||
log.Error("auth: bbolt.Bucket")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bkt.Delete(sess)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Put: %s", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("auth: bbolt.Commit: %s", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("auth: removed session from DB")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkSessionResult is the result of checking a session.
|
|
||||||
type checkSessionResult int
|
|
||||||
|
|
||||||
// checkSessionResult constants.
|
|
||||||
const (
|
|
||||||
checkSessionOK checkSessionResult = 0
|
|
||||||
checkSessionNotFound checkSessionResult = -1
|
|
||||||
checkSessionExpired checkSessionResult = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkSession checks if the session is valid.
|
|
||||||
func (a *Auth) checkSession(sess string) (res checkSessionResult) {
|
|
||||||
now := uint32(time.Now().UTC().Unix())
|
|
||||||
update := false
|
|
||||||
|
|
||||||
a.lock.Lock()
|
|
||||||
defer a.lock.Unlock()
|
|
||||||
|
|
||||||
s, ok := a.sessions[sess]
|
|
||||||
if !ok {
|
|
||||||
return checkSessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.expire <= now {
|
|
||||||
delete(a.sessions, sess)
|
|
||||||
key, _ := hex.DecodeString(sess)
|
|
||||||
a.removeSessionFromFile(key)
|
|
||||||
|
|
||||||
return checkSessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
newExpire := now + a.sessionTTL
|
|
||||||
if s.expire/(24*60*60) != newExpire/(24*60*60) {
|
|
||||||
// update expiration time once a day
|
|
||||||
update = true
|
|
||||||
s.expire = newExpire
|
|
||||||
}
|
|
||||||
|
|
||||||
if update {
|
|
||||||
key, _ := hex.DecodeString(sess)
|
|
||||||
if a.storeSession(key, s) {
|
|
||||||
log.Debug("auth: updated session %s: expire=%d", sess, s.expire)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkSessionOK
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeSession removes the session from the active sessions and the disk.
|
|
||||||
func (a *Auth) removeSession(sess string) {
|
|
||||||
key, _ := hex.DecodeString(sess)
|
|
||||||
a.lock.Lock()
|
|
||||||
delete(a.sessions, sess)
|
|
||||||
a.lock.Unlock()
|
|
||||||
a.removeSessionFromFile(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addUser adds a new user with the given password.
|
|
||||||
func (a *Auth) addUser(u *webUser, password string) (err error) {
|
|
||||||
if len(password) == 0 {
|
if len(password) == 0 {
|
||||||
return errors.Error("empty password")
|
return errors.Error("empty password")
|
||||||
}
|
}
|
||||||
@@ -323,97 +137,129 @@ func (a *Auth) addUser(u *webUser, password string) (err error) {
|
|||||||
|
|
||||||
u.PasswordHash = string(hash)
|
u.PasswordHash = string(hash)
|
||||||
|
|
||||||
a.lock.Lock()
|
err = a.users.Create(ctx, u.toUser())
|
||||||
defer a.lock.Unlock()
|
if err != nil {
|
||||||
|
// Should not happen.
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
a.users = append(a.users, *u)
|
a.logger.DebugContext(ctx, "added user", "login", u.Name)
|
||||||
|
|
||||||
log.Debug("auth: added user with login %q", u.Name)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUser returns a user if there is one.
|
// findUser returns a user if one exists with the provided login and the
|
||||||
func (a *Auth) findUser(login, password string) (u webUser, ok bool) {
|
// password matches.
|
||||||
a.lock.Lock()
|
func (a *Auth) findUser(ctx context.Context, login, password string) (user *aghuser.User) {
|
||||||
defer a.lock.Unlock()
|
user, err := a.users.ByLogin(ctx, aghuser.Login(login))
|
||||||
|
if err != nil {
|
||||||
for _, u = range a.users {
|
return nil
|
||||||
if u.Name == login &&
|
|
||||||
bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
|
|
||||||
return u, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return webUser{}, false
|
ok := user.Password.Authenticate(ctx, password)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCurrentUser returns the current user. It returns an empty User if the
|
// getCurrentUser searches for a user using a cookie or credentials from basic
|
||||||
// user is not found.
|
// authentication.
|
||||||
func (a *Auth) getCurrentUser(r *http.Request) (u webUser) {
|
func (a *Auth) getCurrentUser(r *http.Request) (user *aghuser.User) {
|
||||||
|
ctx := r.Context()
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// There's no Cookie, check Basic authentication.
|
// There's no Cookie, check Basic authentication.
|
||||||
user, pass, ok := r.BasicAuth()
|
user, pass, ok := r.BasicAuth()
|
||||||
if ok {
|
if ok {
|
||||||
u, _ = globalContext.auth.findUser(user, pass)
|
return a.findUser(ctx, user, pass)
|
||||||
|
|
||||||
return u
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return webUser{}
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
a.lock.Lock()
|
sess, err := hex.DecodeString(cookie.Value)
|
||||||
defer a.lock.Unlock()
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(
|
||||||
|
ctx,
|
||||||
|
"searching for user: decoding cookie value",
|
||||||
|
slogutil.KeyError, err,
|
||||||
|
)
|
||||||
|
|
||||||
s, ok := a.sessions[cookie.Value]
|
return nil
|
||||||
if !ok {
|
|
||||||
return webUser{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u = range a.users {
|
var t aghuser.SessionToken
|
||||||
if u.Name == s.userName {
|
copy(t[:], sess)
|
||||||
return u
|
|
||||||
}
|
s, err := a.sessions.FindByToken(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, "searching for user", slogutil.KeyError, err)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return webUser{}
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &aghuser.User{
|
||||||
|
Login: s.UserLogin,
|
||||||
|
ID: s.UserID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSession deletes the session from the active sessions and the disk. It
|
||||||
|
// also logs any occurring errors.
|
||||||
|
func (a *Auth) removeSession(ctx context.Context, cookieSess string) {
|
||||||
|
sess, err := hex.DecodeString(cookieSess)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, "removing session: decoding cookie", slogutil.KeyError, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var t aghuser.SessionToken
|
||||||
|
copy(t[:], sess)
|
||||||
|
|
||||||
|
err = a.sessions.DeleteByToken(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.ErrorContext(ctx, "removing session by token", slogutil.KeyError, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// usersList returns a copy of a users list.
|
// usersList returns a copy of a users list.
|
||||||
func (a *Auth) usersList() (users []webUser) {
|
func (a *Auth) usersList(ctx context.Context) (webUsers []webUser) {
|
||||||
a.lock.Lock()
|
users, err := a.users.All(ctx)
|
||||||
defer a.lock.Unlock()
|
if err != nil {
|
||||||
|
// Should not happen.
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
users = make([]webUser, len(a.users))
|
webUsers = make([]webUser, 0, len(users))
|
||||||
copy(users, a.users)
|
for _, u := range users {
|
||||||
|
webUsers = append(webUsers, webUser{
|
||||||
|
Name: string(u.Login),
|
||||||
|
PasswordHash: string(u.Password.Hash()),
|
||||||
|
UserID: u.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return users
|
return webUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
// authRequired returns true if a authentication is required.
|
// authRequired returns true if a authentication is required.
|
||||||
func (a *Auth) authRequired() bool {
|
func (a *Auth) authRequired(ctx context.Context) (ok bool) {
|
||||||
if GLMode {
|
if GLMode {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
a.lock.Lock()
|
users, err := a.users.All(ctx)
|
||||||
defer a.lock.Unlock()
|
if err != nil {
|
||||||
|
// Should not happen.
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
return len(a.users) != 0
|
return len(users) != 0
|
||||||
}
|
|
||||||
|
|
||||||
// newSessionToken returns cryptographically secure randomly generated slice of
|
|
||||||
// bytes of sessionTokenSize length.
|
|
||||||
//
|
|
||||||
// TODO(e.burkov): Think about using byte array instead of byte slice.
|
|
||||||
func newSessionToken() (data []byte) {
|
|
||||||
randData := make([]byte, sessionTokenSize)
|
|
||||||
|
|
||||||
// Since Go 1.24, crypto/rand.Read doesn't return an error and crashes
|
|
||||||
// unrecoverably instead.
|
|
||||||
_, _ = rand.Read(randData)
|
|
||||||
|
|
||||||
return randData
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package home
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAuth(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
fn := filepath.Join(dir, "sessions.db")
|
|
||||||
|
|
||||||
users := []webUser{{
|
|
||||||
Name: "name",
|
|
||||||
PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2",
|
|
||||||
}}
|
|
||||||
a := InitAuth(fn, nil, 60, nil, nil)
|
|
||||||
s := session{}
|
|
||||||
|
|
||||||
user := webUser{Name: "name"}
|
|
||||||
err := a.addUser(&user, "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, checkSessionNotFound, a.checkSession("notfound"))
|
|
||||||
a.removeSession("notfound")
|
|
||||||
|
|
||||||
sess := newSessionToken()
|
|
||||||
sessStr := hex.EncodeToString(sess)
|
|
||||||
|
|
||||||
now := time.Now().UTC().Unix()
|
|
||||||
// check expiration
|
|
||||||
s.expire = uint32(now)
|
|
||||||
a.addSession(sess, &s)
|
|
||||||
assert.Equal(t, checkSessionExpired, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
// add session with TTL = 2 sec
|
|
||||||
s = session{}
|
|
||||||
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
|
||||||
a.addSession(sess, &s)
|
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
a.Close()
|
|
||||||
|
|
||||||
// load saved session
|
|
||||||
a = InitAuth(fn, users, 60, nil, nil)
|
|
||||||
|
|
||||||
// the session is still alive
|
|
||||||
assert.Equal(t, checkSessionOK, a.checkSession(sessStr))
|
|
||||||
// reset our expiration time because checkSession() has just updated it
|
|
||||||
s.expire = uint32(time.Now().UTC().Unix() + 2)
|
|
||||||
a.storeSession(sess, &s)
|
|
||||||
a.Close()
|
|
||||||
|
|
||||||
u, ok := a.findUser("name", "password")
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, u.Name)
|
|
||||||
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
// load and remove expired sessions
|
|
||||||
a = InitAuth(fn, users, 60, nil, nil)
|
|
||||||
assert.Equal(t, checkSessionNotFound, a.checkSession(sessStr))
|
|
||||||
|
|
||||||
a.Close()
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package home
|
package home
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -32,10 +33,14 @@ type loginJSON struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newCookie creates a new authentication cookie.
|
// newCookie creates a new authentication cookie.
|
||||||
func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error) {
|
func (a *Auth) newCookie(
|
||||||
|
ctx context.Context,
|
||||||
|
req loginJSON,
|
||||||
|
addr string,
|
||||||
|
) (c *http.Cookie, err error) {
|
||||||
rateLimiter := a.rateLimiter
|
rateLimiter := a.rateLimiter
|
||||||
u, ok := a.findUser(req.Name, req.Password)
|
u := a.findUser(ctx, req.Name, req.Password)
|
||||||
if !ok {
|
if u == nil {
|
||||||
if rateLimiter != nil {
|
if rateLimiter != nil {
|
||||||
rateLimiter.inc(addr)
|
rateLimiter.inc(addr)
|
||||||
}
|
}
|
||||||
@@ -47,19 +52,16 @@ func (a *Auth) newCookie(req loginJSON, addr string) (c *http.Cookie, err error)
|
|||||||
rateLimiter.remove(addr)
|
rateLimiter.remove(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess := newSessionToken()
|
s, err := a.sessions.New(ctx, u)
|
||||||
now := time.Now().UTC()
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating session: %w", err)
|
||||||
a.addSession(sess, &session{
|
}
|
||||||
userName: u.Name,
|
|
||||||
expire: uint32(now.Unix()) + a.sessionTTL,
|
|
||||||
})
|
|
||||||
|
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: hex.EncodeToString(sess),
|
Value: hex.EncodeToString(s.Token[:]),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Expires: now.Add(cookieTTL),
|
Expires: time.Now().Add(cookieTTL),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -172,7 +174,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
|
log.Error("auth: getting real ip from request with remote ip %s: %s", remoteIP, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := globalContext.auth.newCookie(req, remoteIP)
|
cookie, err := globalContext.auth.newCookie(r.Context(), req, remoteIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logIP := remoteIP
|
logIP := remoteIP
|
||||||
if globalContext.auth.trustedProxies.Contains(ip.Unmap()) {
|
if globalContext.auth.trustedProxies.Contains(ip.Unmap()) {
|
||||||
@@ -209,7 +211,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalContext.auth.removeSession(c.Value)
|
globalContext.auth.removeSession(r.Context(), c.Value)
|
||||||
|
|
||||||
c = &http.Cookie{
|
c = &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
@@ -242,28 +244,7 @@ func optionalAuthThird(w http.ResponseWriter, r *http.Request) (mustAuth bool) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirect to login page if not authenticated
|
if u := globalContext.auth.getCurrentUser(r); u != nil {
|
||||||
isAuthenticated := false
|
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
|
||||||
if err != nil {
|
|
||||||
// The only error that is returned from r.Cookie is [http.ErrNoCookie].
|
|
||||||
// Check Basic authentication.
|
|
||||||
user, pass, hasBasic := r.BasicAuth()
|
|
||||||
if hasBasic {
|
|
||||||
_, isAuthenticated = globalContext.auth.findUser(user, pass)
|
|
||||||
if !isAuthenticated {
|
|
||||||
log.Info("%s: invalid basic authorization value", pref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res := globalContext.auth.checkSession(cookie.Value)
|
|
||||||
isAuthenticated = res == checkSessionOK
|
|
||||||
if !isAuthenticated {
|
|
||||||
log.Debug("%s: invalid cookie value: %q", pref, cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isAuthenticated {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,14 +270,14 @@ func optionalAuth(
|
|||||||
h func(http.ResponseWriter, *http.Request),
|
h func(http.ResponseWriter, *http.Request),
|
||||||
) (wrapped func(http.ResponseWriter, *http.Request)) {
|
) (wrapped func(http.ResponseWriter, *http.Request)) {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
p := r.URL.Path
|
p := r.URL.Path
|
||||||
authRequired := globalContext.auth != nil && globalContext.auth.authRequired()
|
authRequired := globalContext.auth != nil && globalContext.auth.authRequired(ctx)
|
||||||
if p == "/login.html" {
|
if p == "/login.html" {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if authRequired && err == nil {
|
if authRequired && err == nil {
|
||||||
// Redirect to the dashboard if already authenticated.
|
// Redirect to the dashboard if already authenticated.
|
||||||
res := globalContext.auth.checkSession(cookie.Value)
|
if globalContext.auth.isValidSession(ctx, cookie.Value) {
|
||||||
if res == checkSessionOK {
|
|
||||||
http.Redirect(w, r, "", http.StatusFound)
|
http.Redirect(w, r, "", http.StatusFound)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AdguardTeam/golibs/httphdr"
|
"github.com/AdguardTeam/golibs/httphdr"
|
||||||
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||||
"github.com/AdguardTeam/golibs/testutil"
|
"github.com/AdguardTeam/golibs/testutil"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -33,13 +35,20 @@ func (w *testResponseWriter) WriteHeader(statusCode int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHTTP(t *testing.T) {
|
func TestAuthHTTP(t *testing.T) {
|
||||||
|
var (
|
||||||
|
ctx = testutil.ContextWithTimeout(t, testTimeout)
|
||||||
|
logger = slogutil.NewDiscardLogger()
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
fn := filepath.Join(dir, "sessions.db")
|
fn := filepath.Join(dir, "sessions.db")
|
||||||
|
|
||||||
users := []webUser{
|
users := []webUser{
|
||||||
{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
|
{Name: "name", PasswordHash: "$2y$05$..vyzAECIhJPfaQiOK17IukcQnqEgKJHy0iETyYqxn3YXJl8yZuo2"},
|
||||||
}
|
}
|
||||||
globalContext.auth = InitAuth(fn, users, 60, nil, nil)
|
globalContext.auth, err = InitAuth(ctx, logger, fn, users, time.Minute, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
handlerCalled := false
|
handlerCalled := false
|
||||||
handler := func(_ http.ResponseWriter, _ *http.Request) {
|
handler := func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -68,7 +77,11 @@ func TestAuthHTTP(t *testing.T) {
|
|||||||
assert.True(t, handlerCalled)
|
assert.True(t, handlerCalled)
|
||||||
|
|
||||||
// perform login
|
// perform login
|
||||||
cookie, err := globalContext.auth.newCookie(loginJSON{Name: "name", Password: "password"}, "")
|
cookie, err := globalContext.auth.newCookie(
|
||||||
|
ctx,
|
||||||
|
loginJSON{Name: "name", Password: "password"},
|
||||||
|
"",
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, cookie)
|
require.NotNil(t, cookie)
|
||||||
|
|
||||||
@@ -114,7 +127,7 @@ func TestAuthHTTP(t *testing.T) {
|
|||||||
assert.True(t, handlerCalled)
|
assert.True(t, handlerCalled)
|
||||||
r.Header.Del(httphdr.Cookie)
|
r.Header.Del(httphdr.Cookie)
|
||||||
|
|
||||||
globalContext.auth.Close()
|
globalContext.auth.Close(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRealIP(t *testing.T) {
|
func TestRealIP(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package home
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -748,7 +749,8 @@ func (c *configuration) write(tlsMgr *tlsManager) (err error) {
|
|||||||
defer c.Unlock()
|
defer c.Unlock()
|
||||||
|
|
||||||
if globalContext.auth != nil {
|
if globalContext.auth != nil {
|
||||||
config.Users = globalContext.auth.usersList()
|
// TODO(s.chzhen): Pass context.
|
||||||
|
config.Users = globalContext.auth.usersList(context.TODO())
|
||||||
}
|
}
|
||||||
|
|
||||||
if tlsMgr != nil {
|
if tlsMgr != nil {
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ const PasswordMinRunes = 8
|
|||||||
|
|
||||||
// Apply new configuration, start DNS server, restart Web server
|
// Apply new configuration, start DNS server, restart Web server
|
||||||
func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
|
func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
req, restartHTTP, err := decodeApplyConfigReq(r.Body)
|
req, restartHTTP, err := decodeApplyConfigReq(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
|
aghhttp.Error(r, w, http.StatusBadRequest, "%s", err)
|
||||||
@@ -439,7 +441,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
|||||||
u := &webUser{
|
u := &webUser{
|
||||||
Name: req.Username,
|
Name: req.Username,
|
||||||
}
|
}
|
||||||
err = globalContext.auth.addUser(u, req.Password)
|
err = globalContext.auth.addUser(ctx, u, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
globalContext.firstRun = true
|
globalContext.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
copyInstallSettings(config, curConfig)
|
||||||
@@ -452,7 +454,7 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
|||||||
// moment we'll allow setting up TLS in the initial configuration or the
|
// moment we'll allow setting up TLS in the initial configuration or the
|
||||||
// configuration itself will use HTTPS protocol, because the underlying
|
// configuration itself will use HTTPS protocol, because the underlying
|
||||||
// functions potentially restart the HTTPS server.
|
// functions potentially restart the HTTPS server.
|
||||||
err = startMods(r.Context(), web.baseLogger, web.tlsManager)
|
err = startMods(ctx, web.baseLogger, web.tlsManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
globalContext.firstRun = true
|
globalContext.firstRun = true
|
||||||
copyInstallSettings(config, curConfig)
|
copyInstallSettings(config, curConfig)
|
||||||
@@ -488,11 +490,11 @@ func (web *webAPI) handleInstallConfigure(w http.ResponseWriter, r *http.Request
|
|||||||
// and with its own context, because it waits until all requests are handled
|
// and with its own context, because it waits until all requests are handled
|
||||||
// and will be blocked by it's own caller.
|
// and will be blocked by it's own caller.
|
||||||
go func(timeout time.Duration) {
|
go func(timeout time.Duration) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer slogutil.RecoverAndLog(ctx, web.logger)
|
defer slogutil.RecoverAndLog(shutdownCtx, web.logger)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
shutdownSrv(ctx, web.logger, web.httpServer)
|
shutdownSrv(shutdownCtx, web.logger, web.httpServer)
|
||||||
}(shutdownTimeout)
|
}(shutdownTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -668,7 +668,7 @@ func run(opts options, clientBuildFS fs.FS, done chan struct{}, sigHdlr *signalH
|
|||||||
GLMode = opts.glinetMode
|
GLMode = opts.glinetMode
|
||||||
|
|
||||||
// Init auth module.
|
// Init auth module.
|
||||||
globalContext.auth, err = initUsers()
|
globalContext.auth, err = initUsers(ctx, slogLogger)
|
||||||
fatalOnError(err)
|
fatalOnError(err)
|
||||||
|
|
||||||
web, err := initWeb(ctx, opts, clientBuildFS, upd, slogLogger, tlsMgr, customURL)
|
web, err := initWeb(ctx, opts, clientBuildFS, upd, slogLogger, tlsMgr, customURL)
|
||||||
@@ -786,7 +786,8 @@ func checkPermissions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// initUsers initializes context auth module. Clears config users field.
|
// initUsers initializes context auth module. Clears config users field.
|
||||||
func initUsers() (auth *Auth, err error) {
|
// baseLogger must not be nil.
|
||||||
|
func initUsers(ctx context.Context, baseLogger *slog.Logger) (auth *Auth, err error) {
|
||||||
sessFilename := filepath.Join(globalContext.getDataDir(), "sessions.db")
|
sessFilename := filepath.Join(globalContext.getDataDir(), "sessions.db")
|
||||||
|
|
||||||
var rateLimiter *authRateLimiter
|
var rateLimiter *authRateLimiter
|
||||||
@@ -799,10 +800,17 @@ func initUsers() (auth *Auth, err error) {
|
|||||||
|
|
||||||
trustedProxies := netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies))
|
trustedProxies := netutil.SliceSubnetSet(netutil.UnembedPrefixes(config.DNS.TrustedProxies))
|
||||||
|
|
||||||
sessionTTL := time.Duration(config.HTTPConfig.SessionTTL).Seconds()
|
auth, err = InitAuth(
|
||||||
auth = InitAuth(sessFilename, config.Users, uint32(sessionTTL), rateLimiter, trustedProxies)
|
ctx,
|
||||||
if auth == nil {
|
baseLogger,
|
||||||
return nil, errors.Error("initializing auth module failed")
|
sessFilename,
|
||||||
|
config.Users,
|
||||||
|
time.Duration(config.HTTPConfig.SessionTTL),
|
||||||
|
rateLimiter,
|
||||||
|
trustedProxies,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing auth module: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Users = nil
|
config.Users = nil
|
||||||
@@ -916,7 +924,7 @@ func cleanup(ctx context.Context) {
|
|||||||
globalContext.web = nil
|
globalContext.web = nil
|
||||||
}
|
}
|
||||||
if globalContext.auth != nil {
|
if globalContext.auth != nil {
|
||||||
globalContext.auth.Close()
|
globalContext.auth.Close(ctx)
|
||||||
globalContext.auth = nil
|
globalContext.auth = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ type profileJSON struct {
|
|||||||
|
|
||||||
// handleGetProfile is the handler for GET /control/profile endpoint.
|
// handleGetProfile is the handler for GET /control/profile endpoint.
|
||||||
func handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
func handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := ""
|
||||||
u := globalContext.auth.getCurrentUser(r)
|
u := globalContext.auth.getCurrentUser(r)
|
||||||
|
if u != nil {
|
||||||
|
name = string(u.Login)
|
||||||
|
}
|
||||||
|
|
||||||
var resp profileJSON
|
var resp profileJSON
|
||||||
func() {
|
func() {
|
||||||
@@ -55,7 +59,7 @@ func handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer config.RUnlock()
|
defer config.RUnlock()
|
||||||
|
|
||||||
resp = profileJSON{
|
resp = profileJSON{
|
||||||
Name: u.Name,
|
Name: name,
|
||||||
Language: config.Language,
|
Language: config.Language,
|
||||||
Theme: config.Theme,
|
Theme: config.Theme,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user