-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Signed-off-by: p4u <[email protected]>
- Loading branch information
Showing
11 changed files
with
779 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)))) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
Oops, something went wrong.