Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
Signed-off-by: p4u <[email protected]>
  • Loading branch information
p4u committed Jul 30, 2024
1 parent b5b4cb6 commit ca8ea6b
Show file tree
Hide file tree
Showing 11 changed files with 779 additions and 0 deletions.
93 changes: 93 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package api

import (
"fmt"
"net/http"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"

"go.vocdoni.io/dvote/log"
)

const (
jwtExpiration = 720 * time.Hour // 30 days
passwordSalt = "vocdoni" // salt for password hashing
)

// API type represents the API HTTP server with JWT authentication capabilities.
type API struct {
Router *chi.Mux
auth *jwtauth.JWTAuth
}

// New creates a new API HTTP server. It does not start the server. Use Start() for that.
func New(secret string) *API {
return &API{
auth: jwtauth.New("HS256", []byte(secret), nil),
}
}

// Start starts the API HTTP server (non blocking).
func (a *API) Start(host string, port int) {
go func() {
if err := http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), a.router()); err != nil {
log.Fatalf("failed to start the API server: %v", err)
}
}()
}

// router creates the router with all the routes and middleware.
func (a *API) router() http.Handler {
// Create the router with a basic middleware stack
r := chi.NewRouter()
r.Use(cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}).Handler)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Throttle(100))
r.Use(middleware.ThrottleBacklog(5000, 40000, 30*time.Second))
r.Use(middleware.Timeout(30 * time.Second))
// Protected routes
r.Group(func(r chi.Router) {
// Seek, verify and validate JWT tokens
r.Use(jwtauth.Verifier(a.auth))

// Handle valid JWT tokens.
r.Use(a.authenticator)

// Refresh the token
log.Infow("new route", "method", "POST", "path", "/user/refresh")
r.Post("/user/refresh", a.refreshHandler)

// Get the address
log.Infow("new route", "method", "GET", "path", "/user/address")
r.Get("/user/address", a.addressHandler)
})

// Public routes
r.Group(func(r chi.Router) {
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
if _, err := w.Write([]byte(".")); err != nil {
log.Warnw("failed to write ping response", "error", err)
}
})
// Register new users
log.Infow("new route", "method", "POST", "path", "/user/register")
r.Post("/user/register", a.registerHandler)

// Login
log.Infow("new route", "method", "POST", "path", "/user/login")
r.Post("/user/login", a.loginHandler)
})

return r
}
57 changes: 57 additions & 0 deletions api/authenticator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package api

import (
"context"
"crypto/sha256"
"net/http"
"time"

"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)

// authHandler is a handler that authenticates the user and returns a JWT token.
// If successful, the user identifier is added to the HTTP header as `X-User-Id`,
// so that it can be used by the next handlers.
func (a *API) authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
ErrUnauthorized.Write(w)
return
}
if token == nil || jwt.Validate(token, jwt.WithRequiredClaim("userId")) != nil {
ErrUnauthorized.Withf("userId claim not found in JWT token").Write(w)
return
}
// Retrieve the `userId` from the claims and add it to the HTTP header
r.Header.Add("X-User-Id", claims["userId"].(string))
// Token is authenticated, pass it through
next.ServeHTTP(w, r)
})
}

// makeToken creates a JWT token for the given user identifier.
// The token is signed with the API secret, following the JWT specification.
// The token is valid for the period specified on jwtExpiration constant.
func (a *API) makeToken(id string) (*LoginResponse, error) {
j := jwt.New()
if err := j.Set("userId", id); err != nil {
return nil, err
}
if err := j.Set(jwt.ExpirationKey, time.Now().Add(jwtExpiration).UnixNano()); err != nil {
return nil, err
}
lr := LoginResponse{}
lr.Expirity = time.Now().Add(jwtExpiration)
jmap, err := j.AsMap(context.Background())
if err != nil {
return nil, err
}
_, lr.Token, _ = a.auth.Encode(jmap)
return &lr, nil
}

func hashPassword(password string) []byte {
return sha256.New().Sum([]byte(passwordSalt + password))
}
76 changes: 76 additions & 0 deletions api/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package api

import (
"encoding/json"
"fmt"
"net/http"

"go.vocdoni.io/dvote/log"
)

// Error is used by handler functions to wrap errors, assigning a unique error code
// and also specifying which HTTP Status should be used.
type Error struct {
Err error
Code int
HTTPstatus int
}

// MarshalJSON returns a JSON containing Err.Error() and Code. Field HTTPstatus is ignored.
//
// Example output: {"error":"account not found","code":4003}
func (e Error) MarshalJSON() ([]byte, error) {
// This anon struct is needed to actually include the error string,
// since it wouldn't be marshaled otherwise. (json.Marshal doesn't call Err.Error())
return json.Marshal(
struct {
Err string `json:"error"`
Code int `json:"code"`
}{
Err: e.Err.Error(),
Code: e.Code,
})
}

// Error returns the Message contained inside the APIerror
func (e Error) Error() string {
return e.Err.Error()
}

// Write serializes a JSON msg using APIerror.Message and APIerror.Code
// and passes that to ctx.Send()
func (e Error) Write(w http.ResponseWriter) {
msg, err := json.Marshal(e)
if err != nil {
log.Warn(err)
http.Error(w, "marshal failed", http.StatusInternalServerError)
}
http.Error(w, string(msg), e.HTTPstatus)
}

// Withf returns a copy of APIerror with the Sprintf formatted string appended at the end of e.Err
func (e Error) Withf(format string, args ...any) Error {
return Error{
Err: fmt.Errorf("%w: %v", e.Err, fmt.Sprintf(format, args...)),
Code: e.Code,
HTTPstatus: e.HTTPstatus,
}
}

// With returns a copy of APIerror with the string appended at the end of e.Err
func (e Error) With(s string) Error {
return Error{
Err: fmt.Errorf("%w: %v", e.Err, s),
Code: e.Code,
HTTPstatus: e.HTTPstatus,
}
}

// WithErr returns a copy of APIerror with err.Error() appended at the end of e.Err
func (e Error) WithErr(err error) Error {
return Error{
Err: fmt.Errorf("%w: %v", e.Err, err.Error()),
Code: e.Code,
HTTPstatus: e.HTTPstatus,
}
}
37 changes: 37 additions & 0 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//nolint:lll
package api

import (
"fmt"
"net/http"
)

// The custom Error type satisfies the error interface.
// Error() returns a human-readable description of the error.
//
// Error codes in the 40001-49999 range are the user's fault,
// and they return HTTP Status 400 or 404 (or even 204), whatever is most appropriate.
//
// Error codes 50001-59999 are the server's fault
// and they return HTTP Status 500 or 503, or something else if appropriate.
//
// The initial list of errors were more or less grouped by topic, but the list grows with time in a random fashion.
// NEVER change any of the current error codes, only append new errors after the current last 4XXX or 5XXX
// If you notice there's a gap (say, error code 4010, 4011 and 4013 exist, 4012 is missing) DON'T fill in the gap,
// that code was used in the past for some error (not anymore) and shouldn't be reused.
// There's no correlation between Code and HTTP Status,
// for example the fact that Code 4045 returns HTTP Status 404 Not Found is just a coincidence
//
// Do note that HTTPstatus 204 No Content implies the response body will be empty,
// so the Code and Message will actually be discarded, never sent to the client
var (
ErrUnauthorized = Error{Code: 40001, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user not authorized")}
ErrEmailMalformed = Error{Code: 40002, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("email malformed")}
ErrPasswordTooShort = Error{Code: 40003, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("password too short")}
ErrMalformedBody = Error{Code: 40004, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("malformed JSON body")}
ErrDuplicateConflict = Error{Code: 40901, HTTPstatus: http.StatusConflict, Err: fmt.Errorf("duplicate conflict")}
ErrInvalidUserData = Error{Code: 40005, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid user data")}

ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")}
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
)
27 changes: 27 additions & 0 deletions api/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package api

import (
"encoding/json"
"net/http"
"regexp"

"go.vocdoni.io/dvote/log"
)

var regexpEmail = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func isEmailValid(email string) bool {
return regexpEmail.MatchString(email)
}

func httpWriteJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if _, err := w.Write([]byte("\n")); err != nil {
log.Warnw("failed to write on response", "error", err)
}
}
12 changes: 12 additions & 0 deletions api/transaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package api

import (
"encoding/hex"

"go.vocdoni.io/dvote/crypto/ethereum"
)

func signerFromUserEmail(userEmail string) (*ethereum.SignKeys, error) {
signer := ethereum.SignKeys{}
return &signer, signer.AddHexKey(hex.EncodeToString(ethereum.HashRaw([]byte(userEmail))))
}
23 changes: 23 additions & 0 deletions api/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package api

import (
"time"
)

// Register is the request to register a new user.
type Register struct {
Email string `json:"email"`
Password string `json:"password"`
}

// Login is the request to login a user.
type Login struct {
Email string `json:"email"`
Password string `json:"password"`
}

// LoginResponse is the response of the login request which includes the JWT token
type LoginResponse struct {
Token string `json:"token"`
Expirity time.Time `json:"expirity"`
}
Loading

0 comments on commit ca8ea6b

Please sign in to comment.