commit e905f1b0039b8623a19d8d91e16d329bf4645c94 Author: Farhan Khan Date: Thu Apr 2 01:55:44 2026 -0400 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..64dd1b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ---- Build Stage ---- +FROM golang:1.26 AS builder + +WORKDIR /app + +# ---- Step 1: Copy go.mod/go.sum for caching ---- +# Copy only the minimal files first for layer caching +COPY libshared/go.mod libshared/go.sum ./libshared/ +COPY account-manager/go.mod account-manager/go.sum ./account-manager/ + +# ---- Step 2: Download dependencies ---- +RUN go -C libshared mod download +RUN go -C account-manager mod download + +# ---- Step 3: Copy full source code ---- +COPY libshared ./libshared +COPY account-manager ./account-manager + +# ---- Step 4: Create Go workspace inside container ---- +RUN go work init ./account-manager ./libshared + +## Optional: verify workspace +#RUN go work list + +# ---- Step 5: Build binary ---- +WORKDIR /app/account-manager +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server + +# ---- Runtime Stage ---- +FROM gcr.io/distroless/base-debian12 + +WORKDIR /app +COPY --from=builder /app/account-manager/server . + +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/app/server"] \ No newline at end of file diff --git a/crypto.go b/crypto.go new file mode 100644 index 0000000..0b10546 --- /dev/null +++ b/crypto.go @@ -0,0 +1,71 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + memory = 64 * 1024 // 64 MB + iterations = 3 + parallelism = 2 + saltLength = 16 + keyLength = 32 +) + +func generateSalt() ([]byte, error) { + salt := make([]byte, saltLength) + _, err := rand.Read(salt) + return salt, err +} + +func verifyPassword(password, encodedHash string) bool { + + parts := strings.Split(encodedHash, "$") + + params := parts[3] + salt := parts[4] + hash := parts[5] + + var memory uint32 + var iterations uint32 + var parallelism uint8 + + fmt.Sscanf(params, "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism) + + saltBytes, _ := base64.RawStdEncoding.DecodeString(salt) + hashBytes, _ := base64.RawStdEncoding.DecodeString(hash) + + comparisonHash := argon2.IDKey( + []byte(password), + saltBytes, + iterations, + memory, + parallelism, + uint32(len(hashBytes)), + ) + + return subtle.ConstantTimeCompare(hashBytes, comparisonHash) == 1 +} + +func hashPassword(password string, salt []byte) string { + hash := argon2.IDKey( + []byte(password), + salt, + iterations, + memory, + parallelism, + keyLength, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + memory, iterations, parallelism, b64Salt, b64Hash) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85c5932 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module pcloud/new-account + +go 1.25.0 + +require ( + github.com/jackc/pgx/v5 v5.8.0 + golang.org/x/crypto v0.49.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f3a5617 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7b7ee0b --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "crypto/rand" + "errors" + "libshared" + "log" + "math/big" + "net/http" + "regexp" + "strings" +) + +type NewAccountRequest struct { + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Address string `json:"address"` + CountryCode string `json:"country_code"` + Password string `json:"password"` +} + +type APIResponse struct { + Success bool `json:"success"` + AccountID int64 `json:"account_id,omitempty"` + Error string `json:"error,omitempty"` +} + +func generateSecureNumber(digits int) (int64, error) { + upperBound := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(digits)), nil) + n, err := rand.Int(rand.Reader, upperBound) + if err != nil { + return 0, err + } + + return n.Int64(), nil +} + +func validateRequest(req *NewAccountRequest) error { + if strings.TrimSpace(req.Email) == "" { + return errors.New("email is required") + } + if strings.TrimSpace(req.FirstName) == "" { + return errors.New("first_name is required") + } + if strings.TrimSpace(req.LastName) == "" { + return errors.New("last_name is required") + } + if strings.TrimSpace(req.Address) == "" { + return errors.New("address is required") + } + if strings.TrimSpace(req.CountryCode) == "" { + return errors.New("country_code is required") + } + + // Basic email validation + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(req.Email) { + return errors.New("invalid email format") + } + + // Optional: enforce ISO country code length (2 letters) + if len(req.CountryCode) != 2 { + return errors.New("country_code must be 2 characters (ISO code)") + } + + return nil +} + +func main() { + + libshared.Pool = libshared.GetDbPool() + + http.HandleFunc("/account/new", accountNew) + log.Println("Server running on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/new-account.go b/new-account.go new file mode 100644 index 0000000..cdf49d3 --- /dev/null +++ b/new-account.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "encoding/json" + "libshared" + "log" + "net/http" +) + +func accountNew(w http.ResponseWriter, r *http.Request) { + + var accountID int64 + var err error + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req NewAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + apiresponse := libshared.NewAPIResponse("fail", "Invalid JSON request", APIResponse{}) + + json.NewEncoder(w).Encode(apiresponse) + return + } + + if err := validateRequest(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + apiresponse := libshared.NewAPIResponse("fail", err.Error(), APIResponse{}) + json.NewEncoder(w).Encode(apiresponse) + return + } + + for { + accountID, err = generateSecureNumber(13) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + apiresponse := libshared.NewAPIResponse("fail", "failed to generate account ID", APIResponse{}) + json.NewEncoder(w).Encode(apiresponse) + return + } + + accountExist := libshared.Pool.QueryRow(context.Background(), "SELECT FROM accounts WHERE accountid = $1", accountID) + err = accountExist.Scan() + if err != nil { + break + } + } + + _, err = libshared.Pool.Exec( + context.Background(), + "INSERT INTO accounts (accountID, email, first_name, last_name, address, country_code) VALUES ($1, $2, $3, $4, $5, $6)", + accountID, req.Email, req.FirstName, req.LastName, req.Address, req.CountryCode) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + apiresponse := libshared.NewAPIResponse("fail", "failed to create account", APIResponse{}) + json.NewEncoder(w).Encode(apiresponse) + return + } + + salt, _ := generateSalt() + hashText := hashPassword(req.Password, salt) + + _, err = libshared.Pool.Exec(context.Background(), + "INSERT INTO identities (accountid, provider, provider_user_id, password_hash) VALUES ($1, $2, $3, $4)", + accountID, "local", "root", hashText) + if err != nil { + log.Println("Failed to create root identity for new account:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, err = libshared.Pool.Exec(context.Background(), + "INSERT INTO roles (accountid, rolename, description) VALUES ($1, $2, $3)", + accountID, "admin", "Administrative Role with full permissions") + if err != nil { + log.Println("Failed to create administrative role:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + log.Println("Just Created new root account") + log.Println(accountID, "local", "root", hashText) + + w.Header().Set("Content-Type", "application/json") + apiresponse := libshared.NewAPIResponse("success", "Account created successfully", APIResponse{ + Success: true, + AccountID: accountID, + }) + + json.NewEncoder(w).Encode(apiresponse) + + log.Println("New account created with ID:", accountID, req.Email, req.FirstName, req.LastName, req.Address, req.CountryCode) +} diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..642b829 --- /dev/null +++ b/notes.md @@ -0,0 +1,14 @@ +## Example query + +``` +curl -X POST http://localhost:8080/account/new \ + -H "Content-Type: application/json" \ + -d '{ + "email":"john@example.com", + "first_name":"John", + "last_name":"Smith", + "address":"123 Main St", + "country_code":"US", + "password":"letmein" + }' +``` \ No newline at end of file