Skip to content

Instantly share code, notes, and snippets.

@kalafut
Last active April 30, 2024 11:38
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kalafut/993fbcadf7b59fb85681d02722be8ffb to your computer and use it in GitHub Desktop.
Save kalafut/993fbcadf7b59fb85681d02722be8ffb to your computer and use it in GitHub Desktop.
package main
import (
"log"
"time"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
)
var unauthorizedErr = apis.NewUnauthorizedError("Invalid or expired OTP token", nil)
var badEmailErr = apis.NewBadRequestError("Invalid or unknown email", nil)
func main() {
app := pocketbase.New()
// To support OTP authentication, a new collection (otp_auth) was created with the following fields:
//
// otp - the generated OTP token
// user - relation to the user record
// expiration - the expiration date of the token
// attempts - the number of failed attempts to verify the token
//
// The user requests login and receives an opaque verifyToken in response,
// and the OTP is sent to the user's email. The user then submits the OTP
// and verifyToken to the server for verification.
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// TODO: add a cron job to delete expired otp_auth records
// This is not a security related and can be done occasionally.
// Step 1: User requests an OTP token
//
// The otp-auth endpoint is used to generate an otp_auth record. If the user's email
// is found, the OTP is stored, emailed, and verifyToken (this record's ID) is returned
// to the caller.
e.Router.POST("/otp-auth", func(c echo.Context) error {
data := apis.RequestInfo(c).Data
email, ok := data["email"].(string)
if !ok {
return badEmailErr
}
user, err := app.Dao().FindAuthRecordByEmail("users", email)
if err != nil {
return badEmailErr
}
collection, err := app.Dao().FindCollectionByNameOrId("otp_auth")
if err != nil {
log.Println("find collection error", err)
return apis.NewBadRequestError("", nil)
}
record := models.NewRecord(collection)
record.Set("user", user.GetId())
record.Set("expiration", time.Now().Add(time.Minute*10))
otp := security.RandomStringWithAlphabet(6, "0123456789")
record.Set("otp", otp)
log.Println("otp", otp) // For testing purposes. Normally, you would send this to the user's email.
if err := app.Dao().SaveRecord(record); err != nil {
log.Println("save error", err)
return apis.NewBadRequestError("", nil)
}
return c.JSON(200, echo.Map{
"verifyToken": record.GetId(),
})
})
// Step 2: User submits the OTP token for verification
//
// The otp-verify endpoint is used to verify the OTP token. If the token is valid,
// the user is authenticated and the standard RecordAuthResponse is returned.
e.Router.POST("/otp-verify", func(c echo.Context) error {
data := struct {
VerifyToken string `json:"verifyToken"`
OTP string `json:"otp"`
}{}
if err := c.Bind(&data); err != nil {
log.Println("bind error", err)
return unauthorizedErr
}
record, err := app.Dao().FindRecordById("otp_auth", data.VerifyToken)
if err != nil {
return unauthorizedErr
}
// validate expiration
if record.GetDateTime("expiration").Time().Before(time.Now()) {
app.Dao().DeleteRecord(record)
return unauthorizedErr
}
// validate otp
if !security.Equal(record.GetString("otp"), data.OTP) {
attempts := record.GetInt("attempts") + 1
if attempts > 3 {
app.Dao().DeleteRecord(record)
return unauthorizedErr
}
record.Set("attempts", attempts)
if err := app.Dao().SaveRecord(record); err != nil {
log.Println("save error", err)
}
return unauthorizedErr
}
// At this point the OTP record is consumed and should be deleted
defer app.Dao().DeleteRecord(record)
if err := app.Dao().ExpandRecord(record, []string{"user"}, nil); len(err) > 0 {
log.Println("expand error", err)
return unauthorizedErr
}
user := record.ExpandedOne("user")
if user == nil {
return unauthorizedErr
}
return apis.RecordAuthResponse(app, c, user, nil)
})
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
// Example Flutter snippets to interact with the OTP auth
// Step 1
// Start the auth with an email
final resp = await pb.send("/otp-auth", method: "POST", body: {
"email": emailController.text,
});
// Save this token for the next step
final otpToken = resp['verifyToken'];
//
// ... email with OTP is received and we want to complete the auth...
//
// Step 2
final resp = await pb.send("/otp-verify", method: "POST", body: {
"verifyToken": widget.otpToken,
"otp": otpController.text,
});
// Complete the process by updating the auth store.
final auth = RecordAuth.fromJson(resp);
pb.authStore.save(auth.token, auth.record);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment