308 lines
8.0 KiB
Go
308 lines
8.0 KiB
Go
package server
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/StevanFreeborn/chirpy/internal/api"
|
||
|
|
"github.com/StevanFreeborn/chirpy/internal/auth"
|
||
|
|
"github.com/StevanFreeborn/chirpy/internal/database"
|
||
|
|
"github.com/lib/pq"
|
||
|
|
)
|
||
|
|
|
||
|
|
const accessTokenTTL = time.Hour
|
||
|
|
|
||
|
|
type createUserRequest struct {
|
||
|
|
Email string `json:"email"`
|
||
|
|
Password string `json:"password"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type userResponse struct {
|
||
|
|
Id string `json:"id"`
|
||
|
|
CreatedAt string `json:"created_at"`
|
||
|
|
UpdatedAt string `json:"updated_at"`
|
||
|
|
Email string `json:"email"`
|
||
|
|
IsChirpyRed bool `json:"is_chirpy_red"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// toUserResponse maps a database User to its API response shape.
|
||
|
|
func toUserResponse(u database.User) userResponse {
|
||
|
|
return userResponse{
|
||
|
|
Id: u.ID.String(),
|
||
|
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||
|
|
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||
|
|
Email: u.Email,
|
||
|
|
IsChirpyRed: u.IsChirpyRed,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleCreateUser registers a new user and returns it.
|
||
|
|
func (s *Server) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||
|
|
createUserRequest, err := api.DecodeJSON[createUserRequest](r)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
trimmedEmail := strings.TrimSpace(createUserRequest.Email)
|
||
|
|
|
||
|
|
if trimmedEmail == "" {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, "email is required. must be valid email address.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
trimmedPassword := strings.TrimSpace(createUserRequest.Password)
|
||
|
|
|
||
|
|
if trimmedPassword == "" {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, "password is required. must be non-empty string.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
hashedPassword, err := auth.HashPassword(trimmedPassword)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Uh oh we were unable to create a new user")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
createUserParams := database.CreateUserParams{
|
||
|
|
Email: trimmedEmail,
|
||
|
|
HashedPassword: hashedPassword,
|
||
|
|
}
|
||
|
|
|
||
|
|
createdUser, err := s.db.CreateUser(r.Context(), createUserParams)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
var pgErr *pq.Error
|
||
|
|
|
||
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||
|
|
api.WriteError(w, http.StatusConflict, "A user with that email already exists")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Uh oh we were unable to create a new user")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
api.WriteJSON(w, http.StatusCreated, toUserResponse(createdUser))
|
||
|
|
}
|
||
|
|
|
||
|
|
type loginRequest struct {
|
||
|
|
Email string `json:"email"`
|
||
|
|
Password string `json:"password"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *loginRequest) Validate() error {
|
||
|
|
if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" {
|
||
|
|
return api.NewError("Email and password must be non-empty string")
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type loginResponse struct {
|
||
|
|
Id string `json:"id"`
|
||
|
|
CreatedAt string `json:"created_at"`
|
||
|
|
UpdatedAt string `json:"updated_at"`
|
||
|
|
Email string `json:"email"`
|
||
|
|
IsChirpyRed bool `json:"is_chirpy_red"`
|
||
|
|
Token string `json:"token"`
|
||
|
|
RefreshToken string `json:"refresh_token"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleLogin authenticates a user and returns new access and refresh tokens.
|
||
|
|
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
loginRequest, err := api.DecodeJSON[loginRequest](r)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := loginRequest.Validate(); err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
existingUser, err := s.db.GetUserByEmail(r.Context(), loginRequest.Email)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "Invalid login request")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
isCorrectPassword, err := auth.CheckPasswordHash(loginRequest.Password, existingUser.HashedPassword)
|
||
|
|
|
||
|
|
if err != nil || !isCorrectPassword {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "Invalid login request")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
accessToken, err := auth.MakeJWT(existingUser.ID, s.jwtSecret, accessTokenTTL)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Login failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
createRefreshTokenParams := database.CreateRefreshTokenParams{
|
||
|
|
Token: auth.MakeRefreshToken(),
|
||
|
|
ExpiresAt: time.Now().Add(60 * 24 * time.Hour),
|
||
|
|
RevokedAt: sql.NullTime{
|
||
|
|
Valid: false,
|
||
|
|
},
|
||
|
|
UserID: existingUser.ID,
|
||
|
|
}
|
||
|
|
|
||
|
|
createdRefreshToken, err := s.db.CreateRefreshToken(r.Context(), createRefreshTokenParams)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Login failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
user := toUserResponse(existingUser)
|
||
|
|
|
||
|
|
api.WriteJSON(w, http.StatusOK, loginResponse{
|
||
|
|
Id: user.Id,
|
||
|
|
CreatedAt: user.CreatedAt,
|
||
|
|
UpdatedAt: user.UpdatedAt,
|
||
|
|
Email: user.Email,
|
||
|
|
IsChirpyRed: user.IsChirpyRed,
|
||
|
|
Token: accessToken,
|
||
|
|
RefreshToken: createdRefreshToken.Token,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
type refreshTokenResponse struct {
|
||
|
|
Token string `json:"token"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleRefresh exchanges a valid, unrevoked refresh token for a new access
|
||
|
|
// token.
|
||
|
|
func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) {
|
||
|
|
refreshToken, err := auth.GetBearerToken(r.Header)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
existingRefreshToken, err := s.db.GetRefreshTokenByToken(r.Context(), refreshToken)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if existingRefreshToken.ExpiresAt.Before(time.Now()) || existingRefreshToken.RevokedAt.Valid {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "Unable to refresh token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
accessToken, err := auth.MakeJWT(existingRefreshToken.UserID, s.jwtSecret, accessTokenTTL)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Failed to refresh token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO: We should rotate the refresh token
|
||
|
|
|
||
|
|
api.WriteJSON(w, http.StatusOK, refreshTokenResponse{
|
||
|
|
Token: accessToken,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleRevoke revokes a refresh token so it can no longer be used.
|
||
|
|
func (s *Server) HandleRevoke(w http.ResponseWriter, r *http.Request) {
|
||
|
|
refreshToken, err := auth.GetBearerToken(r.Header)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, "No refresh token present in request")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
existingRefreshToken, err := s.db.GetRefreshTokenByToken(r.Context(), refreshToken)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusNotFound, "Unable to revoke token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.RevokeRefreshToken(r.Context(), existingRefreshToken.Token); err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Unable to revoke token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
api.WriteJSON(w, http.StatusNoContent, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
type updateUserRequest struct {
|
||
|
|
Email string `json:"email"`
|
||
|
|
Password string `json:"password"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *updateUserRequest) Validate() error {
|
||
|
|
if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" {
|
||
|
|
return api.NewError("Email and password must be non-empty string")
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleUpdateUser updates the authenticated user's email and password.
|
||
|
|
func (s *Server) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||
|
|
accessToken, err := auth.GetBearerToken(r.Header)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusUnauthorized, "You are not authorized to perform this action")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
updateUserRequest, err := api.DecodeJSON[updateUserRequest](r)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := updateUserRequest.Validate(); err != nil {
|
||
|
|
api.WriteError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
updatedHashedPassword, err := auth.HashPassword(updateUserRequest.Password)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Unable to perform update")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
updateUserParams := database.UpdateUserParams{
|
||
|
|
ID: requestUserId,
|
||
|
|
Email: updateUserRequest.Email,
|
||
|
|
HashedPassword: updatedHashedPassword,
|
||
|
|
}
|
||
|
|
|
||
|
|
updatedUser, err := s.db.UpdateUser(r.Context(), updateUserParams)
|
||
|
|
|
||
|
|
if err != nil {
|
||
|
|
api.WriteError(w, http.StatusInternalServerError, "Unable to perform update")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
api.WriteJSON(w, http.StatusOK, toUserResponse(updatedUser))
|
||
|
|
}
|