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 }