Author: Pedro Lucas Porcellis <porcellis@eletrotupi.com>
api: introduce a *very* basic wip auth and api handler
api/api.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++ api/auth.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000000000000000000000000000000000000..6bef5106b2f2c8356a592c2c281b76f93e3e5b73 --- /dev/null +++ b/api/api.go @@ -0,0 +1,91 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" + + "git.eletrotupi.com/momento/database" +) + +type Account struct { + Email string + Password string + Bio string + Url string +} + +func New() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/profile", handleProfile) + mux.HandleFunc("/api/register", handleRegister) + + return WithAuth(mux) +} + +func handleProfile(w http.ResponseWriter, r *http.Request) { + user := Auth(r.Context()) + encoder := json.NewEncoder(w) + + err := encoder.Encode(user) + if err != nil { + panic(err) + } +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not Allowed", http.StatusMethodNotAllowed) + + return + } + + var acc Account + ctx := r.Context() + + // TODO: Add some security measures and deal gracefully with problems like + // malformed body, etc + err := json.NewDecoder(r.Body).Decode(&acc) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if acc.Email == "" || acc.Password == "" { + http.Error(w, "Missing required fields", http.StatusUnprocessableEntity) + return + } + + if !strings.ContainsRune(acc.Email, '@') { + http.Error(w, "Invalid Email Address", http.StatusUnprocessableEntity) + return + } + + pwhash, err := bcrypt.GenerateFromPassword([]byte(acc.Password), bcrypt.DefaultCost) + + if err != nil { + http.Error(w, "Problem when hashing", http.StatusInternalServerError) + return + } + + var userID int + if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { + row := tx.QueryRowContext(ctx, ` + INSERT INTO users ( + created_at, email, password, bio, url + ) VALUES ( + NOW() at time zone 'utc', + $1, $2, $3, $4 + ) + RETURNING id; + `, acc.Email, string(pwhash), acc.Bio, acc.Url) + // TODO: Detect duplicate users + return row.Scan(&userID) + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..423f3bc7a294ae94ee6f58c9938fef8d2477f422 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "net/http" + "time" + + "golang.org/x/crypto/bcrypt" + + "git.eletrotupi.com/momento/database" +) + +type User struct { + ID int `json:"id"` + CreatedAt time.Time `json:"created_at"` + Email string `json:"email"` +} + +var userCtxKey = &contextKey{"user"} + +type contextKey struct { + name string +} + +func Context(ctx context.Context, user *User) context.Context { + return context.WithValue(ctx, userCtxKey, user) +} + +func Auth(ctx context.Context) *User { + user, ok := ctx.Value(userCtxKey).(*User) + + if !ok { + panic(errors.New("Invalid authentication context")) + } + + return user +} + +func WithAuth(h http.Handler) http.Handler { + middleware := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/register" { + h.ServeHTTP(w, r) + return + } + + // TODO: Replace this with the final auth mechanism + email, password, ok := r.BasicAuth() + if !ok { + http.Error(w, "Authorization required", http.StatusUnauthorized) + return + } + + var ( + user User + pwhash string + ) + + if err := database.WithTx(r.Context(), &sql.TxOptions{ + Isolation: 0, + ReadOnly: true, + }, func(tx *sql.Tx) error { + row := tx.QueryRowContext(r.Context(), ` + SELECT + id, created_at, email, password + FROM users + WHERE email = $1; + `, email) + return row.Scan(&user.ID, &user.CreatedAt, &user.Email, &pwhash) + }); err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + panic(err) + } + + err := bcrypt.CompareHashAndPassword([]byte(pwhash), []byte(password)) + + if err != nil { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + ctx := Context(r.Context(), &user) + r = r.WithContext(ctx) + h.ServeHTTP(w, r) + } + + return http.HandlerFunc(middleware) +} +