mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-10-26 11:27:18 +00:00
Pull request 2414: AGDNS-2743-auth-middleware
Merge in DNS/adguard-home from AGDNS-2743-auth-middleware to master
Squashed commit of the following:
commit 3d0621f1992fd42ecdf3f5b1039d938db065e478
Merge: 320581ba7 57dc791f5
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Fri May 30 16:38:22 2025 +0300
Merge branch 'master' into AGDNS-2743-auth-middleware
commit 320581ba7693183a39c53dc9f71180ab2a4f4d88
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu May 29 21:39:13 2025 +0300
all: imp code
commit 4de3e7e9baa4459ce0c18d229c809dedeb59a590
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu May 29 17:46:51 2025 +0300
home: imp tests
commit 6556aa48e77bd11254f097a888c51b6c5cd931b9
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Wed May 28 22:53:01 2025 +0300
all: add tests
commit 338eea6699bd2f3ae654c2f1f65fe44f43f4b1dd
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Tue May 27 15:53:31 2025 +0300
home: ctx key web user
commit 0453e5a9717954308331ea1aff2e43257c0f5569
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date: Thu May 22 14:28:09 2025 +0300
home: auth mw
This commit is contained in:
parent
57dc791f58
commit
21834ee3c5
@ -5,8 +5,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionTokenLength is the length of the web user session token.
|
||||
const SessionTokenLength = 16
|
||||
|
||||
// SessionToken is the type for the web user session token.
|
||||
type SessionToken [16]byte
|
||||
type SessionToken [SessionTokenLength]byte
|
||||
|
||||
// NewSessionToken returns a cryptographically secure randomly generated web
|
||||
// user session token. If an error occurs during random generation, it will
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path"
|
||||
@ -12,11 +14,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||
"github.com/AdguardTeam/golibs/errors"
|
||||
"github.com/AdguardTeam/golibs/httphdr"
|
||||
"github.com/AdguardTeam/golibs/log"
|
||||
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
||||
"github.com/AdguardTeam/golibs/netutil"
|
||||
"github.com/AdguardTeam/golibs/timeutil"
|
||||
"github.com/AdguardTeam/golibs/validate"
|
||||
)
|
||||
|
||||
// cookieTTL is the time-to-live of the session cookie.
|
||||
@ -348,3 +353,154 @@ func (a *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func optionalAuthHandler(handler http.Handler) http.Handler {
|
||||
return &authHandler{handler}
|
||||
}
|
||||
|
||||
const (
|
||||
// errInvalidLogin is returned when there is an invalid login attempt.
|
||||
errInvalidLogin errors.Error = "invalid username or password"
|
||||
)
|
||||
|
||||
// authMiddlewareDefaultConfig is the configuration structure for the default
|
||||
// authentication middleware.
|
||||
type authMiddlewareDefaultConfig struct {
|
||||
// logger is used for logging the operation of the middleware. It must not
|
||||
// be nil.
|
||||
logger *slog.Logger
|
||||
|
||||
// sessions contains web user sessions. It must not be nil.
|
||||
sessions aghuser.SessionStorage
|
||||
|
||||
// users contains web user information. It must not be nil.
|
||||
users aghuser.DB
|
||||
}
|
||||
|
||||
// authMiddlewareDefault is the default authentication middleware. It searches
|
||||
// for a web client using an authentication cookie or basic auth credentials and
|
||||
// passes it with the context.
|
||||
type authMiddlewareDefault struct {
|
||||
logger *slog.Logger
|
||||
sessions aghuser.SessionStorage
|
||||
users aghuser.DB
|
||||
}
|
||||
|
||||
// newAuthMiddlewareDefault returns the new properly initialized
|
||||
// *authMiddlewareDefault.
|
||||
func newAuthMiddlewareDefault(c *authMiddlewareDefaultConfig) (mw *authMiddlewareDefault) {
|
||||
return &authMiddlewareDefault{
|
||||
logger: c.logger,
|
||||
sessions: c.sessions,
|
||||
users: c.users,
|
||||
}
|
||||
}
|
||||
|
||||
// wrap adds authentication logic to the provided HTTP handler.
|
||||
func (mw *authMiddlewareDefault) wrap(h http.Handler) (wrapped http.Handler) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !mw.needsAuthentication(ctx, r) {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
u, err := mw.userFromRequest(ctx, r)
|
||||
if u != nil {
|
||||
h.ServeHTTP(w, r.WithContext(withWebUser(ctx, u)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
mw.logger.ErrorContext(ctx, "retrieving user from request", slogutil.KeyError, err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
// needsAuthentication returns true if the current request requires
|
||||
// authentication.
|
||||
//
|
||||
// TODO(s.chzhen): Use the request's path.
|
||||
func (mw *authMiddlewareDefault) needsAuthentication(
|
||||
ctx context.Context,
|
||||
_ *http.Request,
|
||||
) (ok bool) {
|
||||
users, err := mw.users.All(ctx)
|
||||
if err != nil {
|
||||
// Should not happen.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// userFromRequest tries to retrieve a user based on the request.
|
||||
func (mw *authMiddlewareDefault) userFromRequest(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
) (u *aghuser.User, err error) {
|
||||
defer func() { err = errors.Annotate(err, "getting user from request: %w") }()
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == http.ErrNoCookie {
|
||||
return mw.userFromRequestBasicAuth(ctx, r)
|
||||
}
|
||||
|
||||
sess, err := hex.DecodeString(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding cookie: %w", err)
|
||||
}
|
||||
|
||||
l := aghuser.SessionTokenLength
|
||||
|
||||
// TODO(a.garipov): Add validate.Len.
|
||||
err = validate.InRange("token length", len(sess), l, l)
|
||||
if err != nil {
|
||||
// Don't wrap the error because it's informative enough as is.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := aghuser.SessionToken(sess)
|
||||
s, err := mw.sessions.FindByToken(ctx, t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching session by token: %w", err)
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err = mw.users.ByLogin(ctx, s.UserLogin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searching user by login %q: %w", s.UserLogin, err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// userFromRequestBasicAuth searches for a user using Basic Auth credentials.
|
||||
func (mw *authMiddlewareDefault) userFromRequestBasicAuth(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
) (user *aghuser.User, err error) {
|
||||
login, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("credentials: %w", errors.ErrNoValue)
|
||||
}
|
||||
|
||||
user, _ = mw.users.ByLogin(ctx, aghuser.Login(login))
|
||||
if user == nil {
|
||||
return nil, errInvalidLogin
|
||||
}
|
||||
|
||||
ok = user.Password.Authenticate(ctx, pass)
|
||||
if !ok {
|
||||
return nil, errInvalidLogin
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@ -2,7 +2,12 @@ package home
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@ -10,10 +15,12 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||
"github.com/AdguardTeam/golibs/httphdr"
|
||||
"github.com/AdguardTeam/golibs/testutil"
|
||||
"github.com/josharian/native"
|
||||
@ -22,6 +29,280 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// testSessionStorage is the mock implementation of the [aghuser.SessionStorage]
|
||||
// interface.
|
||||
type testSessionStorage struct {
|
||||
onNew func(ctx context.Context, u *aghuser.User) (s *aghuser.Session, err error)
|
||||
onFindByToken func(
|
||||
ctx context.Context,
|
||||
t aghuser.SessionToken,
|
||||
) (s *aghuser.Session, err error)
|
||||
onDeleteByToken func(ctx context.Context, t aghuser.SessionToken) (err error)
|
||||
onClose func() (err error)
|
||||
}
|
||||
|
||||
// type check
|
||||
var _ aghuser.SessionStorage = (*testSessionStorage)(nil)
|
||||
|
||||
// newTestSessionStorage returns a new *testSessionStorage all methods of which
|
||||
// panic.
|
||||
func newTestSessionStorage() (ts *testSessionStorage) {
|
||||
return &testSessionStorage{
|
||||
onNew: func(_ context.Context, u *aghuser.User) (_ *aghuser.Session, _ error) {
|
||||
panic(fmt.Errorf("unexpected call to testSessionStorage.New(%v)", u))
|
||||
},
|
||||
onFindByToken: func(
|
||||
_ context.Context,
|
||||
t aghuser.SessionToken,
|
||||
) (_ *aghuser.Session, err error) {
|
||||
panic(fmt.Errorf("unexpected call to testSessionStorage.FindByToken(%v)", t))
|
||||
},
|
||||
onDeleteByToken: func(_ context.Context, t aghuser.SessionToken) (_ error) {
|
||||
panic(fmt.Errorf("unexpected call to testSessionStorage.DeleteByToken(%v)", t))
|
||||
},
|
||||
onClose: func() (_ error) {
|
||||
panic("unexpected call to testSessionStorage.Close")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// New implements the [aghuser.SessionStorage] interface for
|
||||
// *testSessionStorage.
|
||||
func (ts *testSessionStorage) New(
|
||||
ctx context.Context,
|
||||
u *aghuser.User,
|
||||
) (s *aghuser.Session, err error) {
|
||||
return ts.onNew(ctx, u)
|
||||
}
|
||||
|
||||
// FindByToken implements the [aghuser.SessionStorage] interface for
|
||||
// *testSessionStorage.
|
||||
func (ts *testSessionStorage) FindByToken(
|
||||
ctx context.Context,
|
||||
t aghuser.SessionToken,
|
||||
) (s *aghuser.Session, err error) {
|
||||
return ts.onFindByToken(ctx, t)
|
||||
}
|
||||
|
||||
// DeleteByToken implements the [aghuser.SessionStorage] interface for
|
||||
// *testSessionStorage.
|
||||
func (ts *testSessionStorage) DeleteByToken(
|
||||
ctx context.Context,
|
||||
t aghuser.SessionToken,
|
||||
) (err error) {
|
||||
return ts.onDeleteByToken(ctx, t)
|
||||
}
|
||||
|
||||
// Close implements the [aghuser.SessionStorage] interface for
|
||||
// *testSessionStorage.
|
||||
func (ts *testSessionStorage) Close() (err error) {
|
||||
return ts.onClose()
|
||||
}
|
||||
|
||||
// testUsersDB is the mock implementation of the [aghuser.DB] interface.
|
||||
type testUsersDB struct {
|
||||
onAll func(ctx context.Context) (users []*aghuser.User, err error)
|
||||
onByLogin func(ctx context.Context, login aghuser.Login) (u *aghuser.User, err error)
|
||||
onByUUID func(ctx context.Context, id aghuser.UserID) (u *aghuser.User, err error)
|
||||
onCreate func(ctx context.Context, u *aghuser.User) (err error)
|
||||
}
|
||||
|
||||
// newTestUsersDB returns a new *testUsersDB all methods of which panic.
|
||||
func newTestUsersDB() (ts *testUsersDB) {
|
||||
return &testUsersDB{
|
||||
onAll: func(_ context.Context) (_ []*aghuser.User, _ error) {
|
||||
panic("unexpected call to testUsersDB.All")
|
||||
},
|
||||
onByLogin: func(_ context.Context, l aghuser.Login) (_ *aghuser.User, _ error) {
|
||||
panic(fmt.Errorf("unexpected call to testUsersDB.ByLogin(%v)", l))
|
||||
},
|
||||
onByUUID: func(_ context.Context, id aghuser.UserID) (_ *aghuser.User, _ error) {
|
||||
panic(fmt.Errorf("unexpected call to testUsersDB.ByUUID(%v)", id))
|
||||
},
|
||||
onCreate: func(_ context.Context, u *aghuser.User) (_ error) {
|
||||
panic(fmt.Errorf("unexpected call to testUsersDB.Create(%v)", u))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// type check
|
||||
var _ aghuser.DB = (*testUsersDB)(nil)
|
||||
|
||||
// All implements the [aghuser.DB] interface for *testUsersDB.
|
||||
func (db *testUsersDB) All(ctx context.Context) (users []*aghuser.User, err error) {
|
||||
return db.onAll(ctx)
|
||||
}
|
||||
|
||||
// ByLogin implements the [aghuser.DB] interface for *testUsersDB.
|
||||
func (db *testUsersDB) ByLogin(
|
||||
ctx context.Context,
|
||||
login aghuser.Login,
|
||||
) (u *aghuser.User, err error) {
|
||||
return db.onByLogin(ctx, login)
|
||||
}
|
||||
|
||||
// ByUUID implements the [aghuser.DB] interface for *testUsersDB.
|
||||
func (db *testUsersDB) ByUUID(ctx context.Context, id aghuser.UserID) (u *aghuser.User, err error) {
|
||||
return db.onByUUID(ctx, id)
|
||||
}
|
||||
|
||||
// Create implements the [aghuser.DB] interface for *testUsersDB.
|
||||
func (db *testUsersDB) Create(ctx context.Context, u *aghuser.User) (err error) {
|
||||
return db.onCreate(ctx, u)
|
||||
}
|
||||
|
||||
// testAuthHandler is a helper handler used for testing HTTP middleware.
|
||||
type testAuthHandler struct {
|
||||
user *aghuser.User
|
||||
called bool
|
||||
}
|
||||
|
||||
// type check
|
||||
var _ http.Handler = (*testAuthHandler)(nil)
|
||||
|
||||
// ServeHTTP implements the [http.Handler] interface for *testAuthHandler.
|
||||
func (h *testAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.called = true
|
||||
h.user, _ = webUserFromContext(r.Context())
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareDefault_firstRun(t *testing.T) {
|
||||
db := newTestUsersDB()
|
||||
db.onAll = func(_ context.Context) (users []*aghuser.User, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{
|
||||
logger: testLogger,
|
||||
sessions: &testSessionStorage{},
|
||||
users: db,
|
||||
})
|
||||
|
||||
h := &testAuthHandler{}
|
||||
wrapped := mw.wrap(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
wrapped.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.True(t, h.called)
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
login aghuser.Login = "user_login"
|
||||
|
||||
passwordRaw = "user_password"
|
||||
)
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(passwordRaw),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := &aghuser.User{
|
||||
Login: login,
|
||||
Password: aghuser.NewDefaultPassword(string(passwordHash)),
|
||||
}
|
||||
|
||||
var token aghuser.SessionToken
|
||||
_, _ = rand.Read(token[:])
|
||||
|
||||
tokenHex := hex.EncodeToString(token[:])
|
||||
|
||||
users := map[aghuser.Login]*aghuser.User{login: user}
|
||||
usersDB := newTestUsersDB()
|
||||
usersDB.onAll = func(_ context.Context) (us []*aghuser.User, err error) {
|
||||
return slices.Collect(maps.Values(users)), nil
|
||||
}
|
||||
|
||||
usersDB.onByLogin = func(_ context.Context, login aghuser.Login) (u *aghuser.User, err error) {
|
||||
return users[login], nil
|
||||
}
|
||||
|
||||
sessions := map[aghuser.SessionToken]*aghuser.Session{
|
||||
token: {
|
||||
UserLogin: login,
|
||||
},
|
||||
}
|
||||
ts := newTestSessionStorage()
|
||||
ts.onFindByToken = func(
|
||||
_ context.Context,
|
||||
t aghuser.SessionToken,
|
||||
) (s *aghuser.Session, err error) {
|
||||
return sessions[t], nil
|
||||
}
|
||||
|
||||
mw := newAuthMiddlewareDefault(&authMiddlewareDefaultConfig{
|
||||
logger: testLogger,
|
||||
sessions: ts,
|
||||
users: usersDB,
|
||||
})
|
||||
|
||||
reqCookie := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
reqCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tokenHex})
|
||||
|
||||
reqInvalidCookie := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
reqInvalidCookie.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "invalid_cookie"})
|
||||
|
||||
reqBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
reqBasicAuth.SetBasicAuth(string(login), passwordRaw)
|
||||
|
||||
reqInvalidPassBasicAuth := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
reqInvalidPassBasicAuth.SetBasicAuth(string(login), "invalid_password")
|
||||
|
||||
testCases := []struct {
|
||||
req *http.Request
|
||||
wantUser *aghuser.User
|
||||
name string
|
||||
wantCode int
|
||||
}{{
|
||||
req: httptest.NewRequest(http.MethodGet, "/", nil),
|
||||
wantUser: nil,
|
||||
name: "no_auth",
|
||||
wantCode: http.StatusUnauthorized,
|
||||
}, {
|
||||
req: reqCookie,
|
||||
wantUser: user,
|
||||
name: "cookie",
|
||||
wantCode: http.StatusOK,
|
||||
}, {
|
||||
req: reqBasicAuth,
|
||||
wantUser: user,
|
||||
name: "basic_auth",
|
||||
wantCode: http.StatusOK,
|
||||
}, {
|
||||
req: reqInvalidCookie,
|
||||
wantUser: nil,
|
||||
name: "invalid_cookie",
|
||||
wantCode: http.StatusUnauthorized,
|
||||
}, {
|
||||
req: reqInvalidPassBasicAuth,
|
||||
wantUser: nil,
|
||||
name: "invalid_basic_auth",
|
||||
wantCode: http.StatusUnauthorized,
|
||||
}}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &testAuthHandler{}
|
||||
wrapped := mw.wrap(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(w, tc.req)
|
||||
|
||||
assert.Equal(t, tc.wantCode, w.Code)
|
||||
assert.Equal(t, tc.wantUser, h.user)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuth_ServeHTTP_firstRun(t *testing.T) {
|
||||
storeGlobals(t)
|
||||
|
||||
|
||||
56
internal/home/context.go
Normal file
56
internal/home/context.go
Normal file
@ -0,0 +1,56 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/AdguardTeam/AdGuardHome/internal/aghuser"
|
||||
"github.com/AdguardTeam/golibs/errors"
|
||||
)
|
||||
|
||||
// ctxKey is the type for context keys within this package.
|
||||
type ctxKey uint8
|
||||
|
||||
const (
|
||||
ctxKeyWebUser ctxKey = iota
|
||||
)
|
||||
|
||||
// type check
|
||||
var _ fmt.Stringer = ctxKey(0)
|
||||
|
||||
// String implements the [fmt.Stringer] interface for ctxKey.
|
||||
func (k ctxKey) String() (s string) {
|
||||
switch k {
|
||||
case ctxKeyWebUser:
|
||||
return "ctxKeyWebUser"
|
||||
default:
|
||||
panic(fmt.Errorf("ctx key: %w: %d", errors.ErrBadEnumValue, k))
|
||||
}
|
||||
}
|
||||
|
||||
// panicBadType is a helper that panics with a message about the context key and
|
||||
// the expected type.
|
||||
func panicBadType(key ctxKey, v any) {
|
||||
panic(fmt.Errorf("bad type for %s: %T(%[2]v)", key, v))
|
||||
}
|
||||
|
||||
// withWebUser returns a copy of the parent context with the web user added.
|
||||
func withWebUser(ctx context.Context, u *aghuser.User) (withUser context.Context) {
|
||||
return context.WithValue(ctx, ctxKeyWebUser, u)
|
||||
}
|
||||
|
||||
// webUserFromContext returns the web user from the context, if any.
|
||||
func webUserFromContext(ctx context.Context) (u *aghuser.User, ok bool) {
|
||||
const key = ctxKeyWebUser
|
||||
v := ctx.Value(key)
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
u, ok = v.(*aghuser.User)
|
||||
if !ok {
|
||||
panicBadType(key, v)
|
||||
}
|
||||
|
||||
return u, true
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user