feat: Portal, Email Inbound, Discuss + module improvements
- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -35,6 +36,8 @@ type Server struct {
|
||||
// all JS files (except module_loader.js) plus the XML template bundle,
|
||||
// served as a single file to avoid hundreds of individual HTTP requests.
|
||||
jsBundle string
|
||||
|
||||
bus *Bus // Message bus for Discuss long-polling
|
||||
}
|
||||
|
||||
// New creates a new server instance.
|
||||
@@ -128,6 +131,17 @@ func (s *Server) registerRoutes() {
|
||||
|
||||
// CSV export
|
||||
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
||||
s.mux.HandleFunc("/web/export/xlsx", s.handleExportXLSX)
|
||||
|
||||
// Import
|
||||
s.mux.HandleFunc("/web/import/csv", s.handleImportCSV)
|
||||
|
||||
// Post-setup wizard
|
||||
s.mux.HandleFunc("/web/setup/wizard", s.handleSetupWizard)
|
||||
s.mux.HandleFunc("/web/setup/wizard/save", s.handleSetupWizardSave)
|
||||
|
||||
// Bank statement import
|
||||
s.mux.HandleFunc("/web/bank_statement/import", s.handleBankStatementImport)
|
||||
|
||||
// Reports (HTML and PDF report rendering)
|
||||
s.mux.HandleFunc("/report/", s.handleReport)
|
||||
@@ -137,10 +151,16 @@ func (s *Server) registerRoutes() {
|
||||
// Logout & Account
|
||||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||||
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
|
||||
s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
|
||||
|
||||
// Health check
|
||||
s.mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
// Portal routes (external user access)
|
||||
s.registerPortalRoutes()
|
||||
s.registerPortalSignupRoutes()
|
||||
s.registerBusRoutes()
|
||||
|
||||
// Static files (catch-all for /<addon>/static/...)
|
||||
// NOTE: must be last since it's a broad pattern
|
||||
}
|
||||
@@ -255,13 +275,14 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract UID from session, default to 1 (admin) if no session
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
// Extract UID from session — reject if no session (defense in depth)
|
||||
sess := GetSession(r)
|
||||
if sess == nil {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
|
||||
return
|
||||
}
|
||||
uid := sess.UID
|
||||
companyID := sess.CompanyID
|
||||
|
||||
// Create environment for this request
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
@@ -294,6 +315,36 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeJSONRPC(w, req.ID, result, nil)
|
||||
}
|
||||
|
||||
// sensitiveFields lists fields that only admin (uid=1) may write to.
|
||||
// Prevents privilege escalation via field manipulation.
|
||||
var sensitiveFields = map[string]map[string]bool{
|
||||
"ir.cron": {"user_id": true, "model_name": true, "method_name": true},
|
||||
"ir.model.access": {"group_id": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||
"ir.rule": {"domain_force": true, "groups": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||||
"res.users": {"groups_id": true},
|
||||
"res.groups": {"users": true},
|
||||
}
|
||||
|
||||
// checkSensitiveFields blocks non-admin users from writing protected fields.
|
||||
func checkSensitiveFields(env *orm.Environment, model string, vals orm.Values) *RPCError {
|
||||
if env.UID() == 1 || env.IsSuperuser() {
|
||||
return nil
|
||||
}
|
||||
fields, ok := sensitiveFields[model]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for field := range vals {
|
||||
if fields[field] {
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: field %q on %s is admin-only", field, model),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAccess verifies the current user has permission for the operation.
|
||||
// Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check()
|
||||
func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError {
|
||||
@@ -317,8 +368,22 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
||||
`SELECT COUNT(*) FROM ir_model_access a
|
||||
JOIN ir_model m ON m.id = a.model_id
|
||||
WHERE m.model = $1`, model).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return nil // No ACLs defined → open access (like Odoo superuser mode)
|
||||
if err != nil {
|
||||
// DB error → deny access (fail-closed)
|
||||
log.Printf("access: DB error checking ACL for model %s: %v", model, err)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
// No ACL rules defined for this model → deny (fail-closed).
|
||||
// All models should have ACL seed data via seedACLRules().
|
||||
log.Printf("access: no ACL for model %s, denying (fail-closed)", model)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: no ACL rules for %s", model),
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user's groups grant permission
|
||||
@@ -334,7 +399,11 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
|
||||
AND (a.group_id IS NULL OR gu.res_users_id = $2)
|
||||
)`, perm), model, env.UID()).Scan(&granted)
|
||||
if err != nil {
|
||||
return nil // On error, allow (fail-open for now)
|
||||
log.Printf("access: DB error checking ACL grant for model %s: %v", model, err)
|
||||
return &RPCError{
|
||||
Code: 403,
|
||||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||||
}
|
||||
}
|
||||
if !granted {
|
||||
return &RPCError{
|
||||
@@ -379,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
switch params.Method {
|
||||
case "has_group":
|
||||
// Always return true for admin user, stub for now
|
||||
return true, nil
|
||||
// Check if current user belongs to the given group.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.user_has_groups()
|
||||
groupXMLID := ""
|
||||
if len(params.Args) > 0 {
|
||||
groupXMLID, _ = params.Args[0].(string)
|
||||
}
|
||||
if groupXMLID == "" {
|
||||
return false, nil
|
||||
}
|
||||
// Admin always has all groups
|
||||
if env.UID() == 1 {
|
||||
return true, nil
|
||||
}
|
||||
// Parse "module.xml_id" format
|
||||
parts := strings.SplitN(groupXMLID, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false, nil
|
||||
}
|
||||
// Query: does user belong to this group?
|
||||
var exists bool
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM res_groups_res_users_rel gur
|
||||
JOIN ir_model_data imd ON imd.res_id = gur.res_groups_id AND imd.model = 'res.groups'
|
||||
WHERE gur.res_users_id = $1 AND imd.module = $2 AND imd.name = $3
|
||||
)`, env.UID(), parts[0], parts[1]).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return exists, nil
|
||||
|
||||
case "check_access_rights":
|
||||
// Check if current user has the given access right on this model.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.check_access_rights()
|
||||
operation := "read"
|
||||
if len(params.Args) > 0 {
|
||||
if op, ok := params.Args[0].(string); ok {
|
||||
operation = op
|
||||
}
|
||||
}
|
||||
raiseException := true
|
||||
if v, ok := params.KW["raise_exception"].(bool); ok {
|
||||
raiseException = v
|
||||
}
|
||||
accessErr := s.checkAccess(env, params.Model, operation)
|
||||
if accessErr != nil {
|
||||
if raiseException {
|
||||
return nil, accessErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "fields_get":
|
||||
@@ -404,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
vals := parseValuesAt(params.Args, 1)
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
|
||||
// Field-level access control
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ids) > 0 && ids[0] > 0 {
|
||||
// Update existing record(s)
|
||||
err := rs.Browse(ids...).Write(vals)
|
||||
@@ -513,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
case "create":
|
||||
vals := parseValues(params.Args)
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := rs.Create(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
@@ -522,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
case "write":
|
||||
ids := parseIDs(params.Args)
|
||||
vals := parseValuesAt(params.Args, 1)
|
||||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := rs.Browse(ids...).Write(vals)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
@@ -645,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}, nil
|
||||
|
||||
case "get_formview_id":
|
||||
return false, nil
|
||||
// Return the default form view ID for this model.
|
||||
// Mirrors: odoo/orm/models.py BaseModel.get_formview_id()
|
||||
var viewID *int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT id FROM ir_ui_view
|
||||
WHERE model = $1 AND type = 'form' AND active = true
|
||||
ORDER BY priority, id LIMIT 1`,
|
||||
params.Model).Scan(&viewID)
|
||||
if err != nil || viewID == nil {
|
||||
return false, nil
|
||||
}
|
||||
return *viewID, nil
|
||||
|
||||
case "action_get":
|
||||
// Try registered method first (e.g. res.users has its own action_get).
|
||||
// Mirrors: odoo/addons/base/models/res_users.py action_get()
|
||||
model := orm.Registry.Get(params.Model)
|
||||
if model != nil && model.Methods != nil {
|
||||
if method, ok := model.Methods["action_get"]; ok {
|
||||
ids := parseIDs(params.Args)
|
||||
result, err := method(rs.Browse(ids...), params.Args[1:]...)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
|
||||
case "name_create":
|
||||
@@ -665,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
return []interface{}{created.ID(), nameStr}, nil
|
||||
|
||||
case "read_progress_bar":
|
||||
return map[string]interface{}{}, nil
|
||||
return s.handleReadProgressBar(rs, params)
|
||||
|
||||
case "activity_format":
|
||||
return []interface{}{}, nil
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Search activities for this model/record
|
||||
actRS := env.Model("mail.activity")
|
||||
var allActivities []orm.Values
|
||||
for _, id := range ids {
|
||||
domain := orm.And(
|
||||
orm.Leaf("res_model", "=", params.Model),
|
||||
orm.Leaf("res_id", "=", id),
|
||||
orm.Leaf("done", "=", false),
|
||||
)
|
||||
found, err := actRS.Search(domain, orm.SearchOpts{Order: "date_deadline"})
|
||||
if err != nil || found.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
records, err := found.Read([]string{"id", "res_model", "res_id", "activity_type_id", "summary", "note", "date_deadline", "user_id", "state"})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
allActivities = append(allActivities, records...)
|
||||
}
|
||||
if allActivities == nil {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Format M2O fields
|
||||
actSpec := map[string]interface{}{
|
||||
"activity_type_id": map[string]interface{}{},
|
||||
"user_id": map[string]interface{}{},
|
||||
}
|
||||
formatM2OFields(env, "mail.activity", allActivities, actSpec)
|
||||
formatDateFields("mail.activity", allActivities)
|
||||
normalizeNullFields("mail.activity", allActivities)
|
||||
actResult := make([]interface{}, len(allActivities))
|
||||
for i, a := range allActivities {
|
||||
actResult[i] = a
|
||||
}
|
||||
return actResult, nil
|
||||
|
||||
case "action_archive":
|
||||
ids := parseIDs(params.Args)
|
||||
@@ -697,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
}
|
||||
return created.ID(), nil
|
||||
|
||||
case "web_resequence":
|
||||
// Resequence records by their IDs (drag&drop reordering).
|
||||
// Mirrors: odoo/addons/web/models/models.py web_resequence()
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
// Parse field_name (default "sequence")
|
||||
fieldName := "sequence"
|
||||
if v, ok := params.KW["field_name"].(string); ok {
|
||||
fieldName = v
|
||||
}
|
||||
|
||||
// Parse offset (default 0)
|
||||
offset := 0
|
||||
if v, ok := params.KW["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
|
||||
// Check if field exists on the model
|
||||
model := orm.Registry.Get(params.Model)
|
||||
if model == nil || model.GetField(fieldName) == nil {
|
||||
return []orm.Values{}, nil
|
||||
}
|
||||
|
||||
// Update sequence for each record in order
|
||||
for i, id := range ids {
|
||||
if err := rs.Browse(id).Write(orm.Values{fieldName: offset + i}); err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
// Return records via web_read
|
||||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||||
readParams := CallKWParams{
|
||||
Model: params.Model,
|
||||
Method: "web_read",
|
||||
Args: []interface{}{ids},
|
||||
KW: map[string]interface{}{"specification": spec},
|
||||
}
|
||||
return handleWebRead(env, params.Model, readParams)
|
||||
|
||||
case "message_post":
|
||||
// Post a message on the record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
body, _ := params.KW["body"].(string)
|
||||
messageType := "comment"
|
||||
if v, _ := params.KW["message_type"].(string); v != "" {
|
||||
messageType = v
|
||||
}
|
||||
|
||||
// Get author from current user's partner_id
|
||||
var authorID int64
|
||||
if err := env.Tx().QueryRow(env.Ctx(),
|
||||
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||||
).Scan(&authorID); err != nil {
|
||||
log.Printf("warning: message_post author lookup failed: %v", err)
|
||||
}
|
||||
|
||||
// Create mail.message linked to the current model/record
|
||||
var msgID int64
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $6, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
params.Model, ids[0], body, messageType, authorID, env.UID(),
|
||||
).Scan(&msgID)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
return msgID, nil
|
||||
|
||||
case "_message_get_thread":
|
||||
// Get messages for a record's chatter.
|
||||
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
rows, err := env.Tx().Query(env.Ctx(),
|
||||
`SELECT m.id, m.body, m.message_type, m.date,
|
||||
m.author_id, COALESCE(p.name, ''),
|
||||
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||||
FROM mail_message m
|
||||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||||
WHERE m.model = $1 AND m.res_id = $2
|
||||
ORDER BY m.id DESC`,
|
||||
params.Model, ids[0],
|
||||
)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var body, msgType, subject, emailFrom string
|
||||
var date interface{}
|
||||
var authorID int64
|
||||
var authorName string
|
||||
|
||||
if scanErr := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); scanErr != nil {
|
||||
continue
|
||||
}
|
||||
msg := map[string]interface{}{
|
||||
"id": id,
|
||||
"body": body,
|
||||
"message_type": msgType,
|
||||
"date": date,
|
||||
"subject": subject,
|
||||
"email_from": emailFrom,
|
||||
}
|
||||
if authorID > 0 {
|
||||
msg["author_id"] = []interface{}{authorID, authorName}
|
||||
} else {
|
||||
msg["author_id"] = false
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if messages == nil {
|
||||
messages = []map[string]interface{}{}
|
||||
}
|
||||
return messages, nil
|
||||
|
||||
case "read_followers":
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
// Search followers for this model/record
|
||||
followerRS := env.Model("mail.followers")
|
||||
domain := orm.And(
|
||||
orm.Leaf("res_model", "=", params.Model),
|
||||
orm.Leaf("res_id", "in", ids),
|
||||
)
|
||||
found, err := followerRS.Search(domain, orm.SearchOpts{Limit: 100})
|
||||
if err != nil || found.IsEmpty() {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
followerRecords, err := found.Read([]string{"id", "res_model", "res_id", "partner_id"})
|
||||
if err != nil {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
followerSpec := map[string]interface{}{"partner_id": map[string]interface{}{}}
|
||||
formatM2OFields(env, "mail.followers", followerRecords, followerSpec)
|
||||
normalizeNullFields("mail.followers", followerRecords)
|
||||
followerResult := make([]interface{}, len(followerRecords))
|
||||
for i, r := range followerRecords {
|
||||
followerResult[i] = r
|
||||
}
|
||||
return followerResult, nil
|
||||
|
||||
case "get_activity_data":
|
||||
// Return activity summary data for records.
|
||||
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py
|
||||
emptyResult := map[string]interface{}{
|
||||
"activity_types": []interface{}{},
|
||||
"activity_res_ids": map[string]interface{}{},
|
||||
"grouped_activities": map[string]interface{}{},
|
||||
}
|
||||
|
||||
ids := parseIDs(params.Args)
|
||||
if len(ids) == 0 {
|
||||
return emptyResult, nil
|
||||
}
|
||||
|
||||
// Get activity types
|
||||
typeRS := env.Model("mail.activity.type")
|
||||
types, err := typeRS.Search(nil, orm.SearchOpts{Order: "sequence, id"})
|
||||
if err != nil || types.IsEmpty() {
|
||||
return emptyResult, nil
|
||||
}
|
||||
typeRecords, _ := types.Read([]string{"id", "name"})
|
||||
|
||||
typeList := make([]interface{}, len(typeRecords))
|
||||
for i, t := range typeRecords {
|
||||
typeList[i] = t
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"activity_types": typeList,
|
||||
"activity_res_ids": map[string]interface{}{},
|
||||
"grouped_activities": map[string]interface{}{},
|
||||
}, nil
|
||||
|
||||
default:
|
||||
// Try registered business methods on the model.
|
||||
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
|
||||
@@ -732,6 +1114,58 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
|
||||
|
||||
// --- Session / Auth Endpoints ---
|
||||
|
||||
// loginAttemptInfo tracks login attempts for rate limiting.
|
||||
type loginAttemptInfo struct {
|
||||
Count int
|
||||
LastTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
loginAttempts = make(map[string]loginAttemptInfo)
|
||||
loginAttemptsMu sync.Mutex
|
||||
)
|
||||
|
||||
// checkLoginRateLimit returns false if the login is rate-limited (too many attempts).
|
||||
func (s *Server) checkLoginRateLimit(login string) bool {
|
||||
loginAttemptsMu.Lock()
|
||||
defer loginAttemptsMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Periodic cleanup: evict stale entries (>15 min old) to prevent unbounded growth
|
||||
if len(loginAttempts) > 100 {
|
||||
for k, v := range loginAttempts {
|
||||
if now.Sub(v.LastTime) > 15*time.Minute {
|
||||
delete(loginAttempts, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info := loginAttempts[login]
|
||||
|
||||
// Reset after 15 minutes
|
||||
if now.Sub(info.LastTime) > 15*time.Minute {
|
||||
info = loginAttemptInfo{}
|
||||
}
|
||||
|
||||
// Max 10 attempts per 15 minutes
|
||||
if info.Count >= 10 {
|
||||
return false // Rate limited
|
||||
}
|
||||
|
||||
info.Count++
|
||||
info.LastTime = now
|
||||
loginAttempts[login] = info
|
||||
return true
|
||||
}
|
||||
|
||||
// resetLoginRateLimit clears the rate limit counter on successful login.
|
||||
func (s *Server) resetLoginRateLimit(login string) {
|
||||
loginAttemptsMu.Lock()
|
||||
defer loginAttemptsMu.Unlock()
|
||||
delete(loginAttempts, login)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -754,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit login attempts
|
||||
if !s.checkLoginRateLimit(params.Login) {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||
Code: 429, Message: "Too many login attempts. Please try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Query user by login
|
||||
var uid int64
|
||||
var companyID int64
|
||||
@@ -776,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check password (support both bcrypt and plaintext for migration)
|
||||
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
|
||||
// Check password (bcrypt only — no plaintext fallback)
|
||||
if !tools.CheckPassword(hashedPw, params.Password) {
|
||||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||||
Code: 100, Message: "Access Denied: invalid login or password",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Successful login – reset rate limiter
|
||||
s.resetLoginRateLimit(params.Login)
|
||||
|
||||
// Query allowed companies for the user
|
||||
allowedCompanyIDs := []int64{companyID}
|
||||
rows, err := s.pool.Query(r.Context(),
|
||||
`SELECT DISTINCT c.id FROM res_company c
|
||||
WHERE c.active = true
|
||||
ORDER BY c.id`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var cid int64
|
||||
if rows.Scan(&cid) == nil {
|
||||
ids = append(ids, cid)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
allowedCompanyIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
sess := s.sessions.New(uid, companyID, params.Login)
|
||||
sess.AllowedCompanyIDs = allowedCompanyIDs
|
||||
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
@@ -793,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
Value: sess.ID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
@@ -857,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user