diff --git a/api/contest.go b/api/contest.go index c7f1a3c..c93c045 100644 --- a/api/contest.go +++ b/api/contest.go @@ -2,20 +2,43 @@ package api import ( "encoding/json" + "fmt" "log" "net/http" + "lidsol.org/papeador/security" "lidsol.org/papeador/store" ) +type ContestRequestContent struct { + store.Contest + Username string `json:"username"` + JWT string `json:"jwt"` +} + func (api *ApiContext) createContest(w http.ResponseWriter, r *http.Request) { - var in store.Contest + var in ContestRequestContent if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := api.Store.CreateContest(r.Context(), &in); err != nil { + fmt.Printf("E: %v\n", in) + fmt.Printf("E: %v\n", in.Username) + ok, err := security.ValidateJWT(in.JWT, in.Username) + if err != nil { + log.Println("WHATAADF") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "Token inválida", http.StatusUnauthorized) + return + } + + contData := store.Contest{ContestID: in.ContestID, ContestName: in.ContestName} + + if err := api.Store.CreateContest(r.Context(), &contData); err != nil { if err == store.ErrAlreadyExists { http.Error(w, "El nombre del contest ya esta registrado", http.StatusConflict) log.Println("El nombre del contest ya esta registrado") diff --git a/api/user.go b/api/user.go index 22a0bd3..6ec4fbe 100644 --- a/api/user.go +++ b/api/user.go @@ -2,9 +2,11 @@ package api import ( "encoding/json" + "fmt" "log" "net/http" + "lidsol.org/papeador/security" "lidsol.org/papeador/store" ) @@ -16,11 +18,24 @@ func (api *ApiContext) createUser(w http.ResponseWriter, r *http.Request) { return } + fmt.Printf("E: %v\n", in) if err := api.Store.CreateUser(r.Context(), &in); err != nil { if err == store.ErrAlreadyExists { http.Error(w, "El usuario ya está registrado", http.StatusConflict) log.Println("El usuario ya está registrado") return + } else if err == security.ErrInvalidUsername { + http.Error(w, "El nombre de usuario solo puede contener caracteres, número y guiones", http.StatusUnprocessableEntity) + log.Println("La nombre de usuario que se intentó registrar es inválido") + return + } else if err == security.ErrInvalidPassword { + http.Error(w, "La contraseña debe contener al menos de 12 a 64 caracteres, una mayúscula, una minúscula, un número y un caracter especial, sin espacios", http.StatusUnprocessableEntity) + log.Println("La contraseña que se intentó registrar es insegura") + return + } else if err == security.ErrInvalidEmail { + http.Error(w, "El correo que se intenta registrar es inválido", http.StatusUnprocessableEntity) + log.Println("El correo que se intentó registrar es inválido") + return } http.Error(w, err.Error(), http.StatusInternalServerError) log.Println(err) @@ -29,4 +44,4 @@ func (api *ApiContext) createUser(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(in) -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 0c1c08a..c6a5ebd 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,24 @@ module lidsol.org/papeador -go 1.23.0 +go 1.24.0 toolchain go1.24.3 -require modernc.org/sqlite v1.38.2 +require ( + github.com/golang-jwt/jwt/v5 v5.3.0 + golang.org/x/crypto v0.43.0 + modernc.org/sqlite v1.38.2 +) require ( + github.com/cristalhq/jwt/v5 v5.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.37.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index aac187a..ffecb9c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI= +github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -10,6 +14,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= @@ -17,8 +23,8 @@ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= diff --git a/manifest.scm b/manifest.scm index c7b1882..d4bb5dc 100644 --- a/manifest.scm +++ b/manifest.scm @@ -1 +1 @@ -(specifications->manifest (list "sqlite" "go")) +(specifications->manifest (list "sqlite" "go" "podman" "pkg-config" "btrfs-progs-static" "gpgme" "go-github-com-containerd-btrfs-v2" "bat")) diff --git a/security/ensure.go b/security/ensure.go new file mode 100644 index 0000000..ee434f1 --- /dev/null +++ b/security/ensure.go @@ -0,0 +1,59 @@ +package security + +import ( + "net/mail" + "regexp" + "strings" + "errors" +) + +var ( + lower = regexp.MustCompile(`[a-z]`) + upper = regexp.MustCompile(`[A-Z]`) + digit = regexp.MustCompile(`\d`) + special = regexp.MustCompile(`[!@#\$%\^&\*\(\)\-\_\=\+\[\]\{\};:'",.<>\/\?\\\|` + "`~]") + space = regexp.MustCompile(`\s`) + uname = regexp.MustCompile(`^[A-Za-z0-9_-]{3,20}$`) + + ErrInvalidUsername = errors.New("invalid username") + ErrInvalidPassword = errors.New("invalid password") + ErrInvalidEmail = errors.New("invalid email") +) + +func IsValidUsername(username string) error { + + if !uname.MatchString(username) { + return ErrInvalidUsername + } + + return nil +} + +func IsValidPassword(password string) error { + valid_length := len(password) >= 12 && len(password) <= 64 + + if !valid_length { + return ErrInvalidPassword + } + + if !(lower.MatchString(password) && + upper.MatchString(password) && + digit.MatchString(password) && + special.MatchString(password) && + !space.MatchString(password)) { + return ErrInvalidPassword + } + + return nil +} + +func ValidateEmail(email string) (string, error) { + email = strings.TrimSpace(email) + _, err := mail.ParseAddress(email) + + if err != nil { + return "", ErrInvalidEmail + } + + return strings.ToLower(email), nil +} diff --git a/security/hash.go b/security/hash.go new file mode 100644 index 0000000..46eb31a --- /dev/null +++ b/security/hash.go @@ -0,0 +1,39 @@ +package security + +import ( + "crypto/rand" + "golang.org/x/crypto/argon2" +) + +type Params struct { + Memory uint32 + Iterations uint32 + Parallelism uint8 + SaltLength uint32 + KeyLength uint32 +} + +func HashPassword(password string, p *Params) (hash []byte, err error) { + // Generate a cryptographically secure random salt. + salt, err := generateSalt(p.SaltLength) + if err != nil { + return nil, err + } + + // Pass the plaintext password, salt and parameters to the argon2.IDKey + // function. This will generate a hash of the password using the Argon2id + // variant. + hash = argon2.IDKey([]byte(password), salt, p.Iterations, p.Memory, p.Parallelism, p.KeyLength) + + return hash, nil +} + +func generateSalt( n uint32) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} diff --git a/security/jwt.go b/security/jwt.go new file mode 100644 index 0000000..d8ad556 --- /dev/null +++ b/security/jwt.go @@ -0,0 +1,66 @@ +package security + +import ( + "encoding/json" + "os" + "strings" + "time" + + "github.com/cristalhq/jwt/v5" +) + +var secretKey []byte = []byte(os.Getenv("JWT_KEY")) + +func GenerateJWT(username string) (string, error) { + + signer, err := jwt.NewSignerHS(jwt.HS256, secretKey) + if err != nil { + return "", err + } + + claims := &jwt.RegisteredClaims{ + Audience: []string{"admin"}, + ID: "asdf", + Subject: strings.Clone(username), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 100)), + } + + builder := jwt.NewBuilder(signer) + + token, err := builder.Build(claims) + if err != nil { + return "", err + } + + return token.String(), nil +} + +func ValidateJWT(tokenStr, username string) (bool, error) { + verifier, err := jwt.NewVerifierHS(jwt.HS256, secretKey) + if err != nil { + return false, err + } + + tokenBytes := []byte(tokenStr) + newToken, err := jwt.Parse(tokenBytes, verifier) + if err != nil { + return false, err + } + + err = verifier.Verify(newToken) + if err != nil { + return false, err + } + + var newClaims jwt.RegisteredClaims + errClaims := json.Unmarshal(newToken.Claims(), &newClaims) + if errClaims != nil { + return false, err + } + + if newClaims.IsSubject(username) { + return true, nil + } + + return false, nil +} diff --git a/store/sqlite.go b/store/sqlite.go index 0414336..225ee58 100644 --- a/store/sqlite.go +++ b/store/sqlite.go @@ -1,74 +1,118 @@ package store import ( - "context" - "database/sql" + "context" + "database/sql" + // "log" + + "lidsol.org/papeador/security" ) type SQLiteStore struct{ - DB *sql.DB + DB *sql.DB } func NewSQLiteStore(db *sql.DB) Store { - return &SQLiteStore{DB: db} + return &SQLiteStore{DB: db} } func (s *SQLiteStore) CreateUser(ctx context.Context, u *User) error { - // Verify unique username/email - var username string - err := s.DB.QueryRowContext(ctx, "SELECT username FROM user WHERE username=? OR email=?", u.Username, u.Email).Scan(&username) - if err == nil { - return ErrAlreadyExists - } else if err != sql.ErrNoRows { - return err - } - - res, err := s.DB.ExecContext(ctx, "INSERT INTO user (username,passhash,email) VALUES (?, ?, ?)", u.Username, u.Passhash, u.Email) - if err != nil { - return err - } - if id, ierr := res.LastInsertId(); ierr == nil { - u.UserID = id - } - return nil + // Check for valid username + if err:=security.IsValidUsername(u.Username); err != nil { + return err + } + + // Check for a valid email + if email, err := security.ValidateEmail(u.Email); err == nil { + u.Email = email // to_lower and trimmed + } else { + return err + } + + // Check for a secure password + if err:=security.IsValidPassword(u.Password); err != nil { + return err + } + + // Verify duplicated Username/email + var duplicateUsername string + err := s.DB.QueryRowContext(ctx, "SELECT username FROM user WHERE username=? OR email=?", u.Username, u.Email).Scan(&duplicateUsername) + if err == nil { + return ErrAlreadyExists + } else if err != sql.ErrNoRows { + return err + } + + // Initializing params for Argon2 + p := &security.Params{ + Memory: 64 * 1024, + Iterations: 3, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, + } + + // Password hashing + passhash, err := security.HashPassword(u.Password, p); + if err != nil { + return err + } + + // Inserting user + res, err := s.DB.ExecContext(ctx, "INSERT INTO user (username,passhash,email) VALUES (?, ?, ?)", u.Username, passhash, u.Email) + + if err != nil { + return err + } + + token, err := security.GenerateJWT(u.Username) + if err != nil { + return err + } + + if id, ierr := res.LastInsertId(); ierr == nil { + u.UserID = id + u.JWT = token + } + return nil } func (s *SQLiteStore) CreateContest(ctx context.Context, c *Contest) error { - var name string - err := s.DB.QueryRowContext(ctx, "SELECT contest_name FROM contest WHERE contest_name=?", c.ContestName).Scan(&name) - if err == nil { - return ErrAlreadyExists - } else if err != sql.ErrNoRows { - return err - } - - res, err := s.DB.ExecContext(ctx, "INSERT INTO contest (contest_name) VALUES (?)", c.ContestName) - if err != nil { - return err - } - if id, ierr := res.LastInsertId(); ierr == nil { - c.ContestID = id - } - return nil + var name string + err := s.DB.QueryRowContext(ctx, "SELECT contest_name FROM contest WHERE contest_name=?", c.ContestName).Scan(&name) + if err == nil { + return ErrAlreadyExists + } else if err != sql.ErrNoRows { + return err + } + + res, err := s.DB.ExecContext(ctx, "INSERT INTO contest (contest_name) VALUES (?)", c.ContestName) + if err != nil { + return err + } + if id, ierr := res.LastInsertId(); ierr == nil { + c.ContestID = id + } + return nil } func (s *SQLiteStore) CreateProblem(ctx context.Context, p *Problem) error { - if p.ContestID != nil { - var cid int64 - err := s.DB.QueryRowContext(ctx, "SELECT contest_id FROM contest WHERE contest_id=?", *p.ContestID).Scan(&cid) - if err == sql.ErrNoRows { - return ErrNotFound - } else if err != nil { - return err - } - } - - res, err := s.DB.ExecContext(ctx, "INSERT INTO problem (contest_id, creator_id, problem_name, description) VALUES (?, ?, ?, ?)", p.ContestID, p.CreatorID, p.ProblemName, p.Description) - if err != nil { - return err - } - if id, ierr := res.LastInsertId(); ierr == nil { - p.ProblemID = id - } - return nil + if p.ContestID != nil { + var cid int64 + err := s.DB.QueryRowContext(ctx, "SELECT contest_id FROM contest WHERE contest_id=?", *p.ContestID).Scan(&cid) + if err == sql.ErrNoRows { + return ErrNotFound + } else if err != nil { + return err + } + } + + res, err := s.DB.ExecContext(ctx, "INSERT INTO problem (contest_id, creator_id, problem_name, description) VALUES (?, ?, ?, ?)", p.ContestID, p.CreatorID, p.ProblemName, p.Description) + if err != nil { + return err + } + if id, ierr := res.LastInsertId(); ierr == nil { + p.ProblemID = id + } + return nil } diff --git a/store/store.go b/store/store.go index 85808ac..2b85223 100644 --- a/store/store.go +++ b/store/store.go @@ -1,38 +1,39 @@ package store import ( - "context" - "errors" + "context" + "errors" ) var ( - ErrAlreadyExists = errors.New("already exists") - ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") + ErrNotFound = errors.New("not found") ) type User struct { - UserID int64 `json:"user_id"` - Username string `json:"username"` - Passhash string `json:"passhash"` - Email string `json:"email"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + JWT string `json:"jwt"` } type Contest struct { - ContestID int64 `json:"contest_id"` - ContestName string `json:"contest_name"` + ContestID int64 `json:"contest_id"` + ContestName string `json:"contest_name"` } type Problem struct { - ProblemID int64 `json:"problem_id"` - ContestID *int64 `json:"contest_id"` - CreatorID int64 `json:"creator_id"` - ProblemName string `json:"problem_name"` - Description string `json:"description"` + ProblemID int64 `json:"problem_id"` + ContestID *int64 `json:"contest_id"` + CreatorID int64 `json:"creator_id"` + ProblemName string `json:"problem_name"` + Description string `json:"description"` } // Store defines persistence operations used by the HTTP layer. type Store interface { - CreateUser(ctx context.Context, u *User) error - CreateContest(ctx context.Context, c *Contest) error - CreateProblem(ctx context.Context, p *Problem) error + CreateUser(ctx context.Context, u *User) error + CreateContest(ctx context.Context, c *Contest) error + CreateProblem(ctx context.Context, p *Problem) error }