refactor auth pkg into libraries

This commit is contained in:
dhax 2017-10-31 19:10:09 +01:00
parent 521f081ba0
commit aaf0a0928d
26 changed files with 592 additions and 504 deletions

99
auth/pwdless/account.go Normal file
View file

@ -0,0 +1,99 @@
package pwdless
import (
"net/url"
"strings"
"time"
"github.com/dhax/go-base/auth/jwt"
"github.com/go-chi/jwtauth"
validation "github.com/go-ozzo/ozzo-validation"
"github.com/go-ozzo/ozzo-validation/is"
"github.com/go-pg/pg/orm"
)
// Account represents an authenticated application user
type Account struct {
ID int `json:"id"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
LastLogin time.Time `json:"last_login,omitempty"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `sql:",notnull" json:"active"`
Roles []string `pg:",array" json:"roles,omitempty"`
Token []jwt.Token `json:"token,omitempty"`
}
// BeforeInsert hook executed before database insert operation.
func (a *Account) BeforeInsert(db orm.DB) error {
now := time.Now()
if a.CreatedAt.IsZero() {
a.CreatedAt = now
a.UpdatedAt = now
}
return a.Validate()
}
// BeforeUpdate hook executed before database update operation.
func (a *Account) BeforeUpdate(db orm.DB) error {
a.UpdatedAt = time.Now()
return a.Validate()
}
// BeforeDelete hook executed before database delete operation.
func (a *Account) BeforeDelete(db orm.DB) error {
return nil
}
// Validate validates Account struct and returns validation errors.
func (a *Account) Validate() error {
a.Email = strings.TrimSpace(a.Email)
a.Email = strings.ToLower(a.Email)
a.Name = strings.TrimSpace(a.Name)
return validation.ValidateStruct(a,
validation.Field(&a.Email, validation.Required, is.Email, is.LowerCase),
validation.Field(&a.Name, validation.Required, is.ASCII),
)
}
// CanLogin returns true if is user is allowed to login.
func (a *Account) CanLogin() bool {
return a.Active
}
// Claims returns the account's claims to be signed
func (a *Account) Claims() jwtauth.Claims {
return jwtauth.Claims{
"id": a.ID,
"sub": a.Name,
"roles": a.Roles,
}
}
// AccountFilter provides pagination and filtering options on accounts.
type AccountFilter struct {
orm.Pager
Filters url.Values
Order []string
}
// Filter applies an AccountFilter on an orm.Query.
func (f *AccountFilter) Filter(q *orm.Query) (*orm.Query, error) {
q = q.Apply(f.Pager.Paginate)
q = q.Apply(orm.URLFilters(f.Filters))
q = q.Order(f.Order...)
return q, nil
}
// NewAccountFilter returns an AccountFilter with options parsed from request url values.
func NewAccountFilter(v url.Values) AccountFilter {
var f AccountFilter
f.SetURLValues(v)
f.Filters = v
f.Order = v["order"]
return f
}

290
auth/pwdless/api.go Normal file
View file

@ -0,0 +1,290 @@
// Package pwdless provides JSON Web Token (JWT) authentication and authorization middleware.
// It implements a passwordless authentication flow by sending login tokens vie email which are then exchanged for JWT access and refresh tokens.
package pwdless
import (
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/dhax/go-base/auth/jwt"
"github.com/dhax/go-base/email"
"github.com/dhax/go-base/logging"
"github.com/go-chi/chi"
"github.com/go-chi/render"
validation "github.com/go-ozzo/ozzo-validation"
"github.com/go-ozzo/ozzo-validation/is"
"github.com/mssola/user_agent"
uuid "github.com/satori/go.uuid"
"github.com/sirupsen/logrus"
)
// AuthStorer defines database operations on accounts and tokens.
type AuthStorer interface {
GetAccount(id int) (*Account, error)
GetAccountByEmail(email string) (*Account, error)
UpdateAccount(a *Account) error
GetToken(token string) (*jwt.Token, error)
CreateOrUpdateToken(t *jwt.Token) error
DeleteToken(t *jwt.Token) error
PurgeExpiredToken() error
}
// Mailer defines methods to send account emails.
type Mailer interface {
LoginToken(name, email string, c email.ContentLoginToken) error
}
// Resource implements passwordless account authentication against a database.
type Resource struct {
LoginAuth *LoginTokenAuth
TokenAuth *jwt.TokenAuth
Store AuthStorer
Mailer Mailer
}
// NewResource returns a configured authentication resource.
func NewResource(authStore AuthStorer, mailer Mailer) (*Resource, error) {
loginAuth, err := NewLoginTokenAuth()
if err != nil {
return nil, err
}
tokenAuth, err := jwt.NewTokenAuth()
if err != nil {
return nil, err
}
resource := &Resource{
LoginAuth: loginAuth,
TokenAuth: tokenAuth,
Store: authStore,
Mailer: mailer,
}
resource.choresTicker()
return resource, nil
}
// Router provides necessary routes for passwordless authentication flow.
func (rs *Resource) Router() *chi.Mux {
r := chi.NewRouter()
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Post("/login", rs.login)
r.Post("/token", rs.token)
r.Group(func(r chi.Router) {
r.Use(rs.TokenAuth.Verifier())
r.Use(jwt.AuthenticateRefreshJWT)
r.Post("/refresh", rs.refresh)
r.Post("/logout", rs.logout)
})
return r
}
func log(r *http.Request) logrus.FieldLogger {
return logging.GetLogEntry(r)
}
type loginRequest struct {
Email string
}
func (body *loginRequest) Bind(r *http.Request) error {
body.Email = strings.TrimSpace(body.Email)
body.Email = strings.ToLower(body.Email)
return validation.ValidateStruct(body,
validation.Field(&body.Email, validation.Required, is.Email),
)
}
func (rs *Resource) login(w http.ResponseWriter, r *http.Request) {
body := &loginRequest{}
if err := render.Bind(r, body); err != nil {
log(r).WithField("email", body.Email).Warn(err)
render.Render(w, r, ErrUnauthorized(ErrInvalidLogin))
return
}
acc, err := rs.Store.GetAccountByEmail(body.Email)
if err != nil {
log(r).WithField("email", body.Email).Warn(err)
render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
return
}
if !acc.CanLogin() {
render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
return
}
lt := rs.LoginAuth.CreateToken(acc.ID)
go func() {
content := email.ContentLoginToken{
Email: acc.Email,
Name: acc.Name,
URL: path.Join(rs.LoginAuth.loginURL, lt.Token),
Token: lt.Token,
Expiry: lt.Expiry,
}
if err := rs.Mailer.LoginToken(acc.Name, acc.Email, content); err != nil {
log(r).WithField("module", "email").Error(err)
}
}()
render.Respond(w, r, http.NoBody)
}
type tokenRequest struct {
Token string `json:"token"`
}
type tokenResponse struct {
Access string `json:"access_token"`
Refresh string `json:"refresh_token"`
}
func (body *tokenRequest) Bind(r *http.Request) error {
body.Token = strings.TrimSpace(body.Token)
return validation.ValidateStruct(body,
validation.Field(&body.Token, validation.Required, is.Alphanumeric),
)
}
func (rs *Resource) token(w http.ResponseWriter, r *http.Request) {
body := &tokenRequest{}
if err := render.Bind(r, body); err != nil {
log(r).Warn(err)
render.Render(w, r, ErrUnauthorized(ErrLoginToken))
return
}
id, err := rs.LoginAuth.GetAccountID(body.Token)
if err != nil {
render.Render(w, r, ErrUnauthorized(ErrLoginToken))
return
}
acc, err := rs.Store.GetAccount(id)
if err != nil {
// account deleted before login token expired
render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
return
}
if !acc.CanLogin() {
render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
return
}
ua := user_agent.New(r.UserAgent())
browser, _ := ua.Browser()
token := &jwt.Token{
Token: uuid.NewV4().String(),
Expiry: time.Now().Add(rs.TokenAuth.JwtRefreshExpiry),
UpdatedAt: time.Now(),
AccountID: acc.ID,
Mobile: ua.Mobile(),
Identifier: fmt.Sprintf("%s on %s", browser, ua.OS()),
}
if err := rs.Store.CreateOrUpdateToken(token); err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
access, refresh, err := rs.TokenAuth.GenTokenPair(acc.Claims(), token.Claims())
if err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
acc.LastLogin = time.Now()
if err := rs.Store.UpdateAccount(acc); err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
render.Respond(w, r, &tokenResponse{
Access: access,
Refresh: refresh,
})
}
func (rs *Resource) refresh(w http.ResponseWriter, r *http.Request) {
rt := jwt.RefreshTokenFromCtx(r.Context())
token, err := rs.Store.GetToken(rt)
if err != nil {
render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
return
}
if time.Now().After(token.Expiry) {
rs.Store.DeleteToken(token)
render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
return
}
acc, err := rs.Store.GetAccount(token.AccountID)
if err != nil {
render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
return
}
if !acc.CanLogin() {
render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
return
}
token.Token = uuid.NewV4().String()
token.Expiry = time.Now().Add(rs.TokenAuth.JwtRefreshExpiry)
token.UpdatedAt = time.Now()
access, refresh, err := rs.TokenAuth.GenTokenPair(acc.Claims(), token.Claims())
if err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
if err := rs.Store.CreateOrUpdateToken(token); err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
acc.LastLogin = time.Now()
if err := rs.Store.UpdateAccount(acc); err != nil {
log(r).Error(err)
render.Render(w, r, ErrInternalServerError)
return
}
render.Respond(w, r, &tokenResponse{
Access: access,
Refresh: refresh,
})
}
func (rs *Resource) logout(w http.ResponseWriter, r *http.Request) {
rt := jwt.RefreshTokenFromCtx(r.Context())
token, err := rs.Store.GetToken(rt)
if err != nil {
render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
return
}
rs.Store.DeleteToken(token)
render.Respond(w, r, http.NoBody)
}

355
auth/pwdless/api_test.go Normal file
View file

@ -0,0 +1,355 @@
package pwdless
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/jwtauth"
"github.com/spf13/viper"
"github.com/dhax/go-base/auth/jwt"
"github.com/dhax/go-base/email"
"github.com/dhax/go-base/logging"
)
var (
auth *Resource
authStore MockAuthStore
mailer email.MockMailer
ts *httptest.Server
)
func TestMain(m *testing.M) {
viper.SetDefault("auth_login_token_length", 8)
viper.SetDefault("auth_login_token_expiry", "11m")
viper.SetDefault("auth_jwt_secret", "random")
viper.SetDefault("log_level", "error")
var err error
auth, err = NewResource(&authStore, &mailer)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
r := chi.NewRouter()
r.Use(logging.NewStructuredLogger(logging.NewLogger()))
r.Mount("/", auth.Router())
ts = httptest.NewServer(r)
code := m.Run()
ts.Close()
os.Exit(code)
}
func TestAuthResource_login(t *testing.T) {
authStore.GetAccountByEmailFn = func(email string) (*Account, error) {
var err error
a := Account{
ID: 1,
Email: email,
Name: "test",
}
switch email {
case "not@exists.io":
err = errors.New("sql no row")
case "disabled@account.io":
a.Active = false
case "valid@account.io":
a.Active = true
}
return &a, err
}
mailer.LoginTokenFn = func(n, e string, c email.ContentLoginToken) error {
return nil
}
tests := []struct {
name string
email string
status int
err error
}{
{"missing", "", http.StatusUnauthorized, ErrInvalidLogin},
{"inexistent", "not@exists.io", http.StatusUnauthorized, ErrUnknownLogin},
{"disabled", "disabled@account.io", http.StatusUnauthorized, ErrLoginDisabled},
{"valid", "valid@account.io", http.StatusOK, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := encode(&loginRequest{Email: tc.email})
if err != nil {
t.Fatal("failed to encode request body")
}
res, body := testRequest(t, ts, "POST", "/login", req, "")
if res.StatusCode != tc.status {
t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status)
}
if tc.err != nil && !strings.Contains(body, tc.err.Error()) {
t.Errorf(" got: %s, expected to contain: %s", body, tc.err.Error())
}
if tc.err == ErrInvalidLogin && authStore.GetAccountByEmailInvoked {
t.Error("GetByLoginToken invoked for invalid email")
}
if tc.err == nil && !mailer.LoginTokenInvoked {
t.Error("emailService.LoginToken not invoked")
}
authStore.GetAccountByEmailInvoked = false
mailer.LoginTokenInvoked = false
})
}
}
func TestAuthResource_token(t *testing.T) {
authStore.GetAccountFn = func(id int) (*Account, error) {
var err error
a := Account{
ID: id,
Active: true,
Name: "test",
}
switch id {
case 2:
a.Active = false
case 3:
// unmodified
default:
err = errors.New("sql no rows")
}
return &a, err
}
authStore.UpdateAccountFn = func(a *Account) error {
a.LastLogin = time.Now()
return nil
}
authStore.CreateOrUpdateTokenFn = func(a *jwt.Token) error {
return nil
}
tests := []struct {
name string
token string
id int
status int
err error
}{
{"invalid", "#§$%", 0, http.StatusUnauthorized, ErrLoginToken},
{"expired", "12345678", 0, http.StatusUnauthorized, ErrLoginToken},
{"deleted_account", "", 1, http.StatusUnauthorized, ErrUnknownLogin},
{"disabled", "", 2, http.StatusUnauthorized, ErrLoginDisabled},
{"valid", "", 3, http.StatusOK, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
token := auth.LoginAuth.CreateToken(tc.id)
if tc.token != "" {
token.Token = tc.token
}
req, err := encode(tokenRequest{Token: token.Token})
if err != nil {
t.Fatal("failed to encode request body")
}
res, body := testRequest(t, ts, "POST", "/token", req, "")
if res.StatusCode != tc.status {
t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status)
}
if tc.err != nil && !strings.Contains(body, tc.err.Error()) {
t.Errorf("got: %s, expected to contain: %s", body, tc.err.Error())
}
if tc.err == ErrLoginToken && authStore.CreateOrUpdateTokenInvoked {
t.Errorf("CreateOrUpdate invoked despite error %s", tc.err.Error())
}
if tc.err == nil && !authStore.CreateOrUpdateTokenInvoked {
t.Error("CreateOrUpdate not invoked")
}
authStore.CreateOrUpdateTokenInvoked = false
})
}
}
func TestAuthResource_refresh(t *testing.T) {
authStore.GetAccountFn = func(id int) (*Account, error) {
a := Account{
Active: true,
Name: "Test",
}
switch id {
case 999:
a.Active = false
}
return &a, nil
}
authStore.UpdateAccountFn = func(a *Account) error {
a.LastLogin = time.Now()
return nil
}
authStore.GetTokenFn = func(token string) (*jwt.Token, error) {
var err error
var t jwt.Token
t.Expiry = time.Now().Add(1 * time.Minute)
switch token {
case "notfound":
err = errors.New("sql no rows")
case "expired":
t.Expiry = time.Now().Add(-1 * time.Minute)
case "disabled":
t.AccountID = 999
}
return &t, err
}
authStore.CreateOrUpdateTokenFn = func(a *jwt.Token) error {
return nil
}
authStore.DeleteTokenFn = func(t *jwt.Token) error {
return nil
}
tests := []struct {
name string
token string
exp time.Duration
status int
err error
}{
{"notfound", "notfound", 1, http.StatusUnauthorized, jwt.ErrTokenExpired},
{"expired", "expired", -1, http.StatusUnauthorized, jwt.ErrTokenUnauthorized},
{"disabled", "disabled", 1, http.StatusUnauthorized, ErrLoginDisabled},
{"valid", "valid", 1, http.StatusOK, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
jwt := genJWT(jwtauth.Claims{"token": tc.token, "exp": time.Minute * tc.exp})
res, body := testRequest(t, ts, "POST", "/refresh", nil, jwt)
if res.StatusCode != tc.status {
t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status)
}
if tc.err != nil && !strings.Contains(body, tc.err.Error()) {
t.Errorf("got: %s, expected error to contain: %s", body, tc.err.Error())
}
if tc.status == http.StatusUnauthorized && authStore.CreateOrUpdateTokenInvoked {
t.Errorf("CreateOrUpdate invoked for status %d", tc.status)
}
if tc.status == http.StatusOK {
if !authStore.GetTokenInvoked {
t.Errorf("GetByToken not invoked")
}
if !authStore.CreateOrUpdateTokenInvoked {
t.Errorf("CreateOrUpdate not invoked")
}
if authStore.DeleteTokenInvoked {
t.Errorf("Delete should not be invoked")
}
}
authStore.GetTokenInvoked = false
authStore.CreateOrUpdateTokenInvoked = false
authStore.DeleteTokenInvoked = false
})
}
}
func TestAuthResource_logout(t *testing.T) {
authStore.GetTokenFn = func(token string) (*jwt.Token, error) {
var err error
t := jwt.Token{
Expiry: time.Now().Add(1 * time.Minute),
}
switch token {
case "notfound":
err = errors.New("sql no rows")
}
return &t, err
}
authStore.DeleteTokenFn = func(a *jwt.Token) error {
return nil
}
tests := []struct {
name string
token string
exp time.Duration
status int
err error
}{
{"notfound", "notfound", 1, http.StatusUnauthorized, jwt.ErrTokenExpired},
{"expired", "valid", -1, http.StatusUnauthorized, jwt.ErrTokenUnauthorized},
{"valid", "valid", 1, http.StatusOK, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
jwt := genJWT(jwtauth.Claims{"token": tc.token, "exp": time.Minute * tc.exp})
res, body := testRequest(t, ts, "POST", "/logout", nil, jwt)
if res.StatusCode != tc.status {
t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status)
}
if tc.err != nil && !strings.Contains(body, tc.err.Error()) {
t.Errorf("got: %x, expected error to contain %s", body, tc.err.Error())
}
if tc.status == http.StatusUnauthorized && authStore.DeleteTokenInvoked {
t.Errorf("Delete invoked for status %d", tc.status)
}
authStore.DeleteTokenInvoked = false
})
}
}
func testRequest(t *testing.T, ts *httptest.Server, method, path string, body io.Reader, token string) (*http.Response, string) {
req, err := http.NewRequest(method, ts.URL+path, body)
if err != nil {
t.Fatal(err)
return nil, ""
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "BEARER "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
return nil, ""
}
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
return nil, ""
}
return resp, string(respBody)
}
func genJWT(c jwtauth.Claims) string {
_, tokenString, _ := auth.TokenAuth.JwtAuth.Encode(c)
return tokenString
}
func encode(v interface{}) (*bytes.Buffer, error) {
data := new(bytes.Buffer)
err := json.NewEncoder(data).Encode(v)
return data, err
}

18
auth/pwdless/chores.go Normal file
View file

@ -0,0 +1,18 @@
package pwdless
import (
"time"
"github.com/dhax/go-base/logging"
)
func (rs *Resource) choresTicker() {
ticker := time.NewTicker(time.Hour * 1)
go func() {
for range ticker.C {
if err := rs.Store.PurgeExpiredToken(); err != nil {
logging.Logger.WithField("chore", "purgeExpiredToken").Error(err)
}
}
}()
}

50
auth/pwdless/errors.go Normal file
View file

@ -0,0 +1,50 @@
package pwdless
import (
"errors"
"net/http"
"github.com/go-chi/render"
)
// The list of error types presented to the end user as error message.
var (
ErrInvalidLogin = errors.New("invalid email address")
ErrUnknownLogin = errors.New("email not registered")
ErrLoginDisabled = errors.New("login for account disabled")
ErrLoginToken = errors.New("invalid or expired login token")
)
// ErrResponse renderer type for handling all sorts of errors.
type ErrResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
AppCode int64 `json:"code,omitempty"` // application-specific error code
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
// Render sets the application-specific error code in AppCode.
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
// ErrUnauthorized renders status 401 Unauthorized with custom error message.
func ErrUnauthorized(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: http.StatusUnauthorized,
StatusText: http.StatusText(http.StatusUnauthorized),
ErrorText: err.Error(),
}
}
// The list of default error types without specific error message.
var (
ErrInternalServerError = &ErrResponse{
HTTPStatusCode: http.StatusInternalServerError,
StatusText: http.StatusText(http.StatusInternalServerError),
}
)

104
auth/pwdless/logintoken.go Normal file
View file

@ -0,0 +1,104 @@
package pwdless
import (
"crypto/rand"
"errors"
"sync"
"time"
"github.com/spf13/viper"
)
var (
errTokenNotFound = errors.New("login token not found")
)
// LoginToken is an in-memory saved token referencing an account ID and an expiry date.
type LoginToken struct {
Token string
AccountID int
Expiry time.Time
}
// LoginTokenAuth implements passwordless login authentication flow using temporary in-memory stored tokens.
type LoginTokenAuth struct {
token map[string]LoginToken
mux sync.RWMutex
loginURL string
loginTokenLength int
loginTokenExpiry time.Duration
}
// NewLoginTokenAuth configures and returns a LoginToken authentication instance.
func NewLoginTokenAuth() (*LoginTokenAuth, error) {
a := &LoginTokenAuth{
token: make(map[string]LoginToken),
loginURL: viper.GetString("auth_login_url"),
loginTokenLength: viper.GetInt("auth_login_token_length"),
loginTokenExpiry: viper.GetDuration("auth_login_token_expiry"),
}
return a, nil
}
// CreateToken creates an in-memory login token referencing account ID. It returns a token containing a random tokenstring and expiry date.
func (a *LoginTokenAuth) CreateToken(id int) LoginToken {
lt := LoginToken{
Token: randStringBytes(a.loginTokenLength),
AccountID: id,
Expiry: time.Now().Add(a.loginTokenExpiry),
}
a.add(lt)
a.purgeExpired()
return lt
}
// GetAccountID looks up the token by tokenstring and returns the account ID or error if token not found or expired.
func (a *LoginTokenAuth) GetAccountID(token string) (int, error) {
lt, exists := a.get(token)
if !exists || time.Now().After(lt.Expiry) {
return 0, errTokenNotFound
}
a.delete(lt.Token)
return lt.AccountID, nil
}
func (a *LoginTokenAuth) get(token string) (LoginToken, bool) {
a.mux.RLock()
lt, ok := a.token[token]
a.mux.RUnlock()
return lt, ok
}
func (a *LoginTokenAuth) add(lt LoginToken) {
a.mux.Lock()
a.token[lt.Token] = lt
a.mux.Unlock()
}
func (a *LoginTokenAuth) delete(token string) {
a.mux.Lock()
delete(a.token, token)
a.mux.Unlock()
}
func (a *LoginTokenAuth) purgeExpired() {
for t, v := range a.token {
if time.Now().After(v.Expiry) {
a.delete(t)
}
}
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randStringBytes(n int) string {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
for k, v := range buf {
buf[k] = letterBytes[v%byte(len(letterBytes))]
}
return string(buf)
}

View file

@ -0,0 +1,69 @@
package pwdless
import "github.com/dhax/go-base/auth/jwt"
// MockAuthStore mocks AuthStorer interface.
type MockAuthStore struct {
GetAccountFn func(id int) (*Account, error)
GetAccountInvoked bool
GetAccountByEmailFn func(email string) (*Account, error)
GetAccountByEmailInvoked bool
UpdateAccountFn func(a *Account) error
UpdateAccountInvoked bool
GetTokenFn func(token string) (*jwt.Token, error)
GetTokenInvoked bool
CreateOrUpdateTokenFn func(t *jwt.Token) error
CreateOrUpdateTokenInvoked bool
DeleteTokenFn func(t *jwt.Token) error
DeleteTokenInvoked bool
PurgeExpiredTokenFn func() error
PurgeExpiredTokenInvoked bool
}
// GetAccount mock returns an account by ID.
func (s *MockAuthStore) GetAccount(id int) (*Account, error) {
s.GetAccountInvoked = true
return s.GetAccountFn(id)
}
// GetAccountByEmail mock returns an account by email.
func (s *MockAuthStore) GetAccountByEmail(email string) (*Account, error) {
s.GetAccountByEmailInvoked = true
return s.GetAccountByEmailFn(email)
}
// UpdateAccount mock upates account data related to authentication.
func (s *MockAuthStore) UpdateAccount(a *Account) error {
s.UpdateAccountInvoked = true
return s.UpdateAccountFn(a)
}
// GetToken mock returns an account and refresh token by token identifier.
func (s *MockAuthStore) GetToken(token string) (*jwt.Token, error) {
s.GetTokenInvoked = true
return s.GetTokenFn(token)
}
// CreateOrUpdateToken mock creates or updates a refresh token.
func (s *MockAuthStore) CreateOrUpdateToken(t *jwt.Token) error {
s.CreateOrUpdateTokenInvoked = true
return s.CreateOrUpdateTokenFn(t)
}
// DeleteToken mock deletes a refresh token.
func (s *MockAuthStore) DeleteToken(t *jwt.Token) error {
s.DeleteTokenInvoked = true
return s.DeleteTokenFn(t)
}
// PurgeExpiredToken mock deletes expired refresh token.
func (s *MockAuthStore) PurgeExpiredToken() error {
s.PurgeExpiredTokenInvoked = true
return s.PurgeExpiredTokenFn()
}