github.com/dhax/go-base@v0.0.0-20231004214136-8be7e5c1972b/auth/pwdless/api.go (about)

     1  // Package pwdless provides JSON Web Token (JWT) authentication and authorization middleware.
     2  // It implements a passwordless authentication flow by sending login tokens vie email which are then exchanged for JWT access and refresh tokens.
     3  package pwdless
     4  
     5  import (
     6  	"fmt"
     7  	"net/http"
     8  	"path"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/dhax/go-base/auth/jwt"
    13  	"github.com/dhax/go-base/email"
    14  	"github.com/dhax/go-base/logging"
    15  	"github.com/go-chi/chi/v5"
    16  	"github.com/go-chi/render"
    17  	validation "github.com/go-ozzo/ozzo-validation"
    18  	"github.com/go-ozzo/ozzo-validation/is"
    19  	"github.com/gofrs/uuid"
    20  	"github.com/mssola/user_agent"
    21  	"github.com/sirupsen/logrus"
    22  )
    23  
    24  // AuthStorer defines database operations on accounts and tokens.
    25  type AuthStorer interface {
    26  	GetAccount(id int) (*Account, error)
    27  	GetAccountByEmail(email string) (*Account, error)
    28  	UpdateAccount(a *Account) error
    29  
    30  	GetToken(token string) (*jwt.Token, error)
    31  	CreateOrUpdateToken(t *jwt.Token) error
    32  	DeleteToken(t *jwt.Token) error
    33  	PurgeExpiredToken() error
    34  }
    35  
    36  // Mailer defines methods to send account emails.
    37  type Mailer interface {
    38  	LoginToken(name, email string, c email.ContentLoginToken) error
    39  }
    40  
    41  // Resource implements passwordless account authentication against a database.
    42  type Resource struct {
    43  	LoginAuth *LoginTokenAuth
    44  	TokenAuth *jwt.TokenAuth
    45  	Store     AuthStorer
    46  	Mailer    Mailer
    47  }
    48  
    49  // NewResource returns a configured authentication resource.
    50  func NewResource(authStore AuthStorer, mailer Mailer) (*Resource, error) {
    51  	loginAuth, err := NewLoginTokenAuth()
    52  	if err != nil {
    53  		return nil, err
    54  	}
    55  
    56  	tokenAuth, err := jwt.NewTokenAuth()
    57  	if err != nil {
    58  		return nil, err
    59  	}
    60  
    61  	resource := &Resource{
    62  		LoginAuth: loginAuth,
    63  		TokenAuth: tokenAuth,
    64  		Store:     authStore,
    65  		Mailer:    mailer,
    66  	}
    67  
    68  	resource.choresTicker()
    69  
    70  	return resource, nil
    71  }
    72  
    73  // Router provides necessary routes for passwordless authentication flow.
    74  func (rs *Resource) Router() *chi.Mux {
    75  	r := chi.NewRouter()
    76  	r.Use(render.SetContentType(render.ContentTypeJSON))
    77  	r.Post("/login", rs.login)
    78  	r.Post("/token", rs.token)
    79  	r.Group(func(r chi.Router) {
    80  		r.Use(rs.TokenAuth.Verifier())
    81  		r.Use(jwt.AuthenticateRefreshJWT)
    82  		r.Post("/refresh", rs.refresh)
    83  		r.Post("/logout", rs.logout)
    84  	})
    85  	return r
    86  }
    87  
    88  func log(r *http.Request) logrus.FieldLogger {
    89  	return logging.GetLogEntry(r)
    90  }
    91  
    92  type loginRequest struct {
    93  	Email string
    94  }
    95  
    96  func (body *loginRequest) Bind(r *http.Request) error {
    97  	body.Email = strings.TrimSpace(body.Email)
    98  	body.Email = strings.ToLower(body.Email)
    99  
   100  	return validation.ValidateStruct(body,
   101  		validation.Field(&body.Email, validation.Required, is.Email),
   102  	)
   103  }
   104  
   105  func (rs *Resource) login(w http.ResponseWriter, r *http.Request) {
   106  	body := &loginRequest{}
   107  	if err := render.Bind(r, body); err != nil {
   108  		log(r).WithField("email", body.Email).Warn(err)
   109  		render.Render(w, r, ErrUnauthorized(ErrInvalidLogin))
   110  		return
   111  	}
   112  
   113  	acc, err := rs.Store.GetAccountByEmail(body.Email)
   114  	if err != nil {
   115  		log(r).WithField("email", body.Email).Warn(err)
   116  		render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
   117  		return
   118  	}
   119  
   120  	if !acc.CanLogin() {
   121  		render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
   122  		return
   123  	}
   124  
   125  	lt := rs.LoginAuth.CreateToken(acc.ID)
   126  
   127  	go func() {
   128  		content := email.ContentLoginToken{
   129  			Email:  acc.Email,
   130  			Name:   acc.Name,
   131  			URL:    path.Join(rs.LoginAuth.loginURL, lt.Token),
   132  			Token:  lt.Token,
   133  			Expiry: lt.Expiry,
   134  		}
   135  		if err := rs.Mailer.LoginToken(acc.Name, acc.Email, content); err != nil {
   136  			log(r).WithField("module", "email").Error(err)
   137  		}
   138  	}()
   139  
   140  	render.Respond(w, r, http.NoBody)
   141  }
   142  
   143  type tokenRequest struct {
   144  	Token string `json:"token"`
   145  }
   146  
   147  type tokenResponse struct {
   148  	Access  string `json:"access_token"`
   149  	Refresh string `json:"refresh_token"`
   150  }
   151  
   152  func (body *tokenRequest) Bind(r *http.Request) error {
   153  	body.Token = strings.TrimSpace(body.Token)
   154  
   155  	return validation.ValidateStruct(body,
   156  		validation.Field(&body.Token, validation.Required, is.Alphanumeric),
   157  	)
   158  }
   159  
   160  func (rs *Resource) token(w http.ResponseWriter, r *http.Request) {
   161  	body := &tokenRequest{}
   162  	if err := render.Bind(r, body); err != nil {
   163  		log(r).Warn(err)
   164  		render.Render(w, r, ErrUnauthorized(ErrLoginToken))
   165  		return
   166  	}
   167  
   168  	id, err := rs.LoginAuth.GetAccountID(body.Token)
   169  	if err != nil {
   170  		render.Render(w, r, ErrUnauthorized(ErrLoginToken))
   171  		return
   172  	}
   173  
   174  	acc, err := rs.Store.GetAccount(id)
   175  	if err != nil {
   176  		// account deleted before login token expired
   177  		render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
   178  		return
   179  	}
   180  
   181  	if !acc.CanLogin() {
   182  		render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
   183  		return
   184  	}
   185  
   186  	ua := user_agent.New(r.UserAgent())
   187  	browser, _ := ua.Browser()
   188  
   189  	token := &jwt.Token{
   190  		Token:      uuid.Must(uuid.NewV4()).String(),
   191  		Expiry:     time.Now().Add(rs.TokenAuth.JwtRefreshExpiry),
   192  		UpdatedAt:  time.Now(),
   193  		AccountID:  acc.ID,
   194  		Mobile:     ua.Mobile(),
   195  		Identifier: fmt.Sprintf("%s on %s", browser, ua.OS()),
   196  	}
   197  
   198  	if err := rs.Store.CreateOrUpdateToken(token); err != nil {
   199  		log(r).Error(err)
   200  		render.Render(w, r, ErrInternalServerError)
   201  		return
   202  	}
   203  
   204  	access, refresh, err := rs.TokenAuth.GenTokenPair(acc.Claims(), token.Claims())
   205  	if err != nil {
   206  		log(r).Error(err)
   207  		render.Render(w, r, ErrInternalServerError)
   208  		return
   209  	}
   210  
   211  	acc.LastLogin = time.Now()
   212  	if err := rs.Store.UpdateAccount(acc); err != nil {
   213  		log(r).Error(err)
   214  		render.Render(w, r, ErrInternalServerError)
   215  		return
   216  	}
   217  
   218  	render.Respond(w, r, &tokenResponse{
   219  		Access:  access,
   220  		Refresh: refresh,
   221  	})
   222  }
   223  
   224  func (rs *Resource) refresh(w http.ResponseWriter, r *http.Request) {
   225  	rt := jwt.RefreshTokenFromCtx(r.Context())
   226  
   227  	token, err := rs.Store.GetToken(rt)
   228  	if err != nil {
   229  		render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
   230  		return
   231  	}
   232  
   233  	if time.Now().After(token.Expiry) {
   234  		rs.Store.DeleteToken(token)
   235  		render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
   236  		return
   237  	}
   238  
   239  	acc, err := rs.Store.GetAccount(token.AccountID)
   240  	if err != nil {
   241  		render.Render(w, r, ErrUnauthorized(ErrUnknownLogin))
   242  		return
   243  	}
   244  
   245  	if !acc.CanLogin() {
   246  		render.Render(w, r, ErrUnauthorized(ErrLoginDisabled))
   247  		return
   248  	}
   249  
   250  	token.Token = uuid.Must(uuid.NewV4()).String()
   251  	token.Expiry = time.Now().Add(rs.TokenAuth.JwtRefreshExpiry)
   252  	token.UpdatedAt = time.Now()
   253  
   254  	access, refresh, err := rs.TokenAuth.GenTokenPair(acc.Claims(), token.Claims())
   255  	if err != nil {
   256  		log(r).Error(err)
   257  		render.Render(w, r, ErrInternalServerError)
   258  		return
   259  	}
   260  
   261  	if err := rs.Store.CreateOrUpdateToken(token); err != nil {
   262  		log(r).Error(err)
   263  		render.Render(w, r, ErrInternalServerError)
   264  		return
   265  	}
   266  
   267  	acc.LastLogin = time.Now()
   268  	if err := rs.Store.UpdateAccount(acc); err != nil {
   269  		log(r).Error(err)
   270  		render.Render(w, r, ErrInternalServerError)
   271  		return
   272  	}
   273  
   274  	render.Respond(w, r, &tokenResponse{
   275  		Access:  access,
   276  		Refresh: refresh,
   277  	})
   278  }
   279  
   280  func (rs *Resource) logout(w http.ResponseWriter, r *http.Request) {
   281  	rt := jwt.RefreshTokenFromCtx(r.Context())
   282  	token, err := rs.Store.GetToken(rt)
   283  	if err != nil {
   284  		render.Render(w, r, ErrUnauthorized(jwt.ErrTokenExpired))
   285  		return
   286  	}
   287  	rs.Store.DeleteToken(token)
   288  
   289  	render.Respond(w, r, http.NoBody)
   290  }