diff --git a/transaction.go b/transaction.go index 96bff635..2c6050f0 100644 --- a/transaction.go +++ b/transaction.go @@ -23,12 +23,100 @@ import "C" import ( "errors" + "fmt" "runtime" "runtime/cgo" "strings" + "sync/atomic" "unsafe" ) +// Type for PAM Return types +type ReturnType int + +// Pam Return types +const ( + // Successful function return + Success ReturnType = C.PAM_SUCCESS + // dlopen() failure when dynamically loading a service module + OpenErr ReturnType = C.PAM_OPEN_ERR + // Symbol not found + SymbolErr ReturnType = C.PAM_SYMBOL_ERR + // Error in service module + ServiceErr ReturnType = C.PAM_SERVICE_ERR + // System error + SystemErr ReturnType = C.PAM_SYSTEM_ERR + // Memory buffer error + BufErr ReturnType = C.PAM_BUF_ERR + // Permission denied + PermDenied ReturnType = C.PAM_PERM_DENIED + // Authentication failure + AuthErr ReturnType = C.PAM_AUTH_ERR + // Can not access authentication data due to insufficient credentials + CredInsufficient ReturnType = C.PAM_CRED_INSUFFICIENT + // Underlying authentication service can not retrieve authentication + // information + AuthinfoUnavail ReturnType = C.PAM_AUTHINFO_UNAVAIL + // User not known to the underlying authentication module + UserUnknown ReturnType = C.PAM_USER_UNKNOWN + // An authentication service has maintained a retry count which has been + // reached. + // No further retries should be attempted + Maxtries ReturnType = C.PAM_MAXTRIES + // New authentication token required. This is normally returned if the + // machine security policies require that the password should be changed + // because the password is nil or it has aged + NewAuthtokReqd ReturnType = C.PAM_NEW_AUTHTOK_REQD + // User account has expired + AcctExpired ReturnType = C.PAM_ACCT_EXPIRED + // Can not make/remove an entry for the specified session + SessionErr ReturnType = C.PAM_SESSION_ERR + // Underlying authentication service can not retrieve user credentials + CredUnavail ReturnType = C.PAM_CRED_UNAVAIL + // User credentials expired + CredExpired ReturnType = C.PAM_CRED_EXPIRED + // Failure setting user credentials + CredErr ReturnType = C.PAM_CRED_ERR + // No module specific data is present + NoModuleData ReturnType = C.PAM_NO_MODULE_DATA + // Conversation error + ConvErr ReturnType = C.PAM_CONV_ERR + // Authentication token manipulation error + AuthtokErr ReturnType = C.PAM_AUTHTOK_ERR + // Authentication information cannot be recovered + AuthtokRecoveryErr ReturnType = C.PAM_AUTHTOK_RECOVERY_ERR + // Authentication token lock busy + AuthtokLockBusy ReturnType = C.PAM_AUTHTOK_LOCK_BUSY + // Authentication token aging disabled + AuthtokDisableAging ReturnType = C.PAM_AUTHTOK_DISABLE_AGING + // Preliminary check by password service + TryAgain ReturnType = C.PAM_TRY_AGAIN + // Ignore underlying account module regardless of whether the control flag + // is required, optional, or sufficient + Ignore ReturnType = C.PAM_IGNORE + // Critical error (?module fail now request) + Abort ReturnType = C.PAM_ABORT + // user's authentication token has expired + AuthtokExpired ReturnType = C.PAM_AUTHTOK_EXPIRED + // module is not known + ModuleUnknown ReturnType = C.PAM_MODULE_UNKNOWN + // Bad item passed to pam_*_item() + BadItem ReturnType = C.PAM_BAD_ITEM + // conversation function is event driven and data is not available yet + ConvAgain ReturnType = C.PAM_CONV_AGAIN + // please call this function again to complete authentication stack. + // Before calling again, verify that conversation is completed + Incomplete ReturnType = C.PAM_INCOMPLETE +) + +func (rt ReturnType) Error() string { + return fmt.Sprintf("%d: %s", rt, C.GoString(C.pam_strerror(nil, C.int(rt)))) +} + +func (rt ReturnType) toC() C.int { + return C.int(rt) +} + // Style is the type of message that the conversation handler should display. type Style int @@ -99,36 +187,36 @@ func cbPAMConv(s C.int, msg *C.char, c C.uintptr_t) (*C.char, C.int) { if style == BinaryPrompt { bytes, err := cb.RespondPAMBinary(BinaryPointer(msg)) if err != nil { - return nil, C.PAM_CONV_ERR + return nil, ConvAgain.toC() } - return (*C.char)(C.CBytes(bytes)), C.PAM_SUCCESS + return (*C.char)(C.CBytes(bytes)), Success.toC() } else { r, err = cb.RespondPAM(style, C.GoString(msg)) } case ConversationHandler: if style == BinaryPrompt { - return nil, C.PAM_AUTHINFO_UNAVAIL + return nil, AuthinfoUnavail.toC() } r, err = cb.RespondPAM(style, C.GoString(msg)) } if err != nil { - return nil, C.PAM_CONV_ERR + return nil, ConvErr.toC() } - return C.CString(r), C.PAM_SUCCESS + return C.CString(r), Success.toC() } // Transaction is the application's handle for a PAM transaction. type Transaction struct { handle *C.pam_handle_t conv *C.struct_pam_conv - status C.int + status int32 c cgo.Handle } // transactionFinalizer cleans up the PAM handle and deletes the callback // function. func transactionFinalizer(t *Transaction) { - C.pam_end(t.handle, t.status) + C.pam_end(t.handle, C.int(atomic.LoadInt32(&t.status))) t.c.Delete() } @@ -155,7 +243,10 @@ func StartFunc(service, user string, handler func(Style, string) (string, error) // transaction provides an interface to the remainder of the API. func StartConfDir(service, user string, handler ConversationHandler, confDir string) (*Transaction, error) { if !CheckPamHasStartConfdir() { - return nil, errors.New("StartConfDir() was used, but the pam version on the system is not recent enough") + return nil, &TransactionError{ + errors.New("StartConfDir() was used, but the pam version on the system is not recent enough"), + SystemErr, + } } return start(service, user, handler, confDir) @@ -165,7 +256,10 @@ func start(service, user string, handler ConversationHandler, confDir string) (* switch handler.(type) { case BinaryConversationHandler: if !CheckPamHasBinaryProtocol() { - return nil, errors.New("BinaryConversationHandler() was used, but it is not supported by this platform") + return nil, &TransactionError{ + errors.New("BinaryConversationHandler() was used, but it is not supported by this platform"), + SystemErr, + } } } t := &Transaction{ @@ -181,21 +275,54 @@ func start(service, user string, handler ConversationHandler, confDir string) (* u = C.CString(user) defer C.free(unsafe.Pointer(u)) } + var status C.int if confDir == "" { - t.status = C.pam_start(s, u, t.conv, &t.handle) + status = C.pam_start(s, u, t.conv, &t.handle) } else { c := C.CString(confDir) defer C.free(unsafe.Pointer(c)) - t.status = C.pam_start_confdir(s, u, t.conv, c, &t.handle) + status = C.pam_start_confdir(s, u, t.conv, c, &t.handle) } - if t.status != C.PAM_SUCCESS { - return nil, t + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { + return nil, &TransactionError{t, ReturnType(status)} } return t, nil } +// transactionError is a private interface that is implemented by both +// TransactionError and Transaction +type transactionError interface { + error + Status() ReturnType +} + +// TransactionError extends error to provide more detailed information +type TransactionError struct { + error + status ReturnType +} + +// Status exposes the ReturnType for the error +func (e *TransactionError) Status() ReturnType { + return e.status +} + +// Error pretty prints the error from the status message +func (e *TransactionError) Error() string { + return errors.Join(e.error, ReturnType(e.status)).Error() +} + func (t *Transaction) Error() string { - return C.GoString(C.pam_strerror(t.handle, C.int(t.status))) + return t.Status().Error() +} + +// Status exposes the ReturnType for the last operation, as per its nature +// this value is not thread-safe and so if multiple goroutines are acting +// on the same transaction this should not be used, but one should rely on +// each operation return status. +func (t *Transaction) Status() ReturnType { + return ReturnType(atomic.LoadInt32(&t.status)) } // Item is a an PAM information type. @@ -225,8 +352,9 @@ const ( func (t *Transaction) SetItem(i Item, item string) error { cs := unsafe.Pointer(C.CString(item)) defer C.free(cs) - t.status = C.pam_set_item(t.handle, C.int(i), cs) - if t.status != C.PAM_SUCCESS { + status := C.pam_set_item(t.handle, C.int(i), cs) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -235,8 +363,9 @@ func (t *Transaction) SetItem(i Item, item string) error { // GetItem retrieves a PAM information item. func (t *Transaction) GetItem(i Item) (string, error) { var s unsafe.Pointer - t.status = C.pam_get_item(t.handle, C.int(i), &s) - if t.status != C.PAM_SUCCESS { + status := C.pam_get_item(t.handle, C.int(i), &s) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return "", t } return C.GoString((*C.char)(s)), nil @@ -274,8 +403,9 @@ const ( // // Valid flags: Silent, DisallowNullAuthtok func (t *Transaction) Authenticate(f Flags) error { - t.status = C.pam_authenticate(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_authenticate(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -286,8 +416,9 @@ func (t *Transaction) Authenticate(f Flags) error { // // Valid flags: EstablishCred, DeleteCred, ReinitializeCred, RefreshCred func (t *Transaction) SetCred(f Flags) error { - t.status = C.pam_setcred(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_setcred(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -297,8 +428,9 @@ func (t *Transaction) SetCred(f Flags) error { // // Valid flags: Silent, DisallowNullAuthtok func (t *Transaction) AcctMgmt(f Flags) error { - t.status = C.pam_acct_mgmt(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_acct_mgmt(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -308,8 +440,9 @@ func (t *Transaction) AcctMgmt(f Flags) error { // // Valid flags: Silent, ChangeExpiredAuthtok func (t *Transaction) ChangeAuthTok(f Flags) error { - t.status = C.pam_chauthtok(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_chauthtok(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -319,8 +452,9 @@ func (t *Transaction) ChangeAuthTok(f Flags) error { // // Valid flags: Slient func (t *Transaction) OpenSession(f Flags) error { - t.status = C.pam_open_session(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_open_session(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -330,8 +464,9 @@ func (t *Transaction) OpenSession(f Flags) error { // // Valid flags: Silent func (t *Transaction) CloseSession(f Flags) error { - t.status = C.pam_close_session(t.handle, C.int(f)) - if t.status != C.PAM_SUCCESS { + status := C.pam_close_session(t.handle, C.int(f)) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -345,8 +480,9 @@ func (t *Transaction) CloseSession(f Flags) error { func (t *Transaction) PutEnv(nameval string) error { cs := C.CString(nameval) defer C.free(unsafe.Pointer(cs)) - t.status = C.pam_putenv(t.handle, cs) - if t.status != C.PAM_SUCCESS { + status := C.pam_putenv(t.handle, cs) + atomic.StoreInt32(&t.status, int32(status)) + if status != Success.toC() { return t } return nil @@ -372,7 +508,7 @@ func (t *Transaction) GetEnvList() (map[string]string, error) { env := make(map[string]string) p := C.pam_getenvlist(t.handle) if p == nil { - t.status = C.PAM_BUF_ERR + atomic.StoreInt32(&t.status, C.PAM_BUF_ERR) return nil, t } for q := p; *q != nil; q = next(q) { diff --git a/transaction_test.go b/transaction_test.go index 94aa9c11..3a9c4149 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -2,7 +2,10 @@ package pam import ( "errors" + "fmt" + "os" "os/user" + "path/filepath" "testing" ) @@ -118,6 +121,13 @@ func TestPAM_005(t *testing.T) { if err != nil { t.Fatalf("start #error: %v", err) } + service, err := tx.GetItem(Service) + if err != nil { + t.Fatalf("GetItem #error: %v", err) + } + if service != "passwd" { + t.Fatalf("Unexpected service: %v", service) + } err = tx.ChangeAuthTok(Silent) if err != nil { t.Fatalf("chauthtok #error: %v", err) @@ -164,6 +174,9 @@ func TestPAM_007(t *testing.T) { if len(s) == 0 { t.Fatalf("error #expected an error message") } + if tx.Status() != AuthErr { + t.Fatalf("error #unexpected status %v", tx.Status()) + } } func TestPAM_ConfDir(t *testing.T) { @@ -180,6 +193,13 @@ func TestPAM_ConfDir(t *testing.T) { // nothing else we do, we don't support it. return } + service, err := tx.GetItem(Service) + if err != nil { + t.Fatalf("GetItem #error: %v", err) + } + if service != "permit-service" { + t.Fatalf("Unexpected service: %v", service) + } if err != nil { t.Fatalf("start #error: %v", err) } @@ -190,6 +210,9 @@ func TestPAM_ConfDir(t *testing.T) { } func TestPAM_ConfDir_FailNoServiceOrUnsupported(t *testing.T) { + if !CheckPamHasStartConfdir() { + t.Skip("this requires PAM with Conf dir support") + } u, _ := user.Current() c := Credentials{ Password: "secret", @@ -202,6 +225,14 @@ func TestPAM_ConfDir_FailNoServiceOrUnsupported(t *testing.T) { if len(s) == 0 { t.Fatalf("error #expected an error message") } + switch et := err.(type) { + case transactionError: + if et.Status() != Abort { + t.Fatalf("error #unexpected status: %v", et.Status()) + } + default: + t.Fatalf("error #unexpected type: %v", et) + } } func TestPAM_ConfDir_InfoMessage(t *testing.T) { @@ -219,6 +250,13 @@ func TestPAM_ConfDir_InfoMessage(t *testing.T) { if err != nil { t.Fatalf("start #error: %v", err) } + service, err := tx.GetItem(Service) + if err != nil { + t.Fatalf("GetItem #error: %v", err) + } + if service != "echo-service" { + t.Fatalf("Unexpected service: %v", service) + } err = tx.Authenticate(0) if err != nil { t.Fatalf("authenticate #error: %v", err) @@ -229,11 +267,21 @@ func TestPAM_ConfDir_InfoMessage(t *testing.T) { } func TestPAM_ConfDir_Deny(t *testing.T) { + if !CheckPamHasStartConfdir() { + t.Skip("this requires PAM with Conf dir support") + } u, _ := user.Current() tx, err := StartConfDir("deny-service", u.Username, Credentials{}, "test-services") if err != nil { t.Fatalf("start #error: %v", err) } + service, err := tx.GetItem(Service) + if err != nil { + t.Fatalf("GetItem #error: %v", err) + } + if service != "deny-service" { + t.Fatalf("Unexpected service: %v", service) + } err = tx.Authenticate(0) if err == nil { t.Fatalf("authenticate #expected an error") @@ -242,6 +290,9 @@ func TestPAM_ConfDir_Deny(t *testing.T) { if len(s) == 0 { t.Fatalf("error #expected an error message") } + if tx.Status() != AuthErr { + t.Fatalf("error #unexpected status %v", tx.Status()) + } } func TestPAM_ConfDir_PromptForUserName(t *testing.T) { @@ -288,6 +339,9 @@ func TestPAM_ConfDir_WrongUserName(t *testing.T) { if len(s) == 0 { t.Fatalf("error #expected an error message") } + if tx.Status() != AuthErr { + t.Fatalf("error #unexpected status %v", tx.Status()) + } } func TestItem(t *testing.T) { @@ -390,6 +444,115 @@ func TestEnv(t *testing.T) { } } +func Test_Status(t *testing.T) { + if !CheckPamHasStartConfdir() { + t.Skip("this requires PAM with Conf dir support") + } + + t.Parallel() + + retTypes := map[string]ReturnType{ + "success": Success, + "open_err": OpenErr, + "symbol_err": SymbolErr, + "service_err": ServiceErr, + "system_err": SystemErr, + "buf_err": BufErr, + "perm_denied": PermDenied, + "auth_err": AuthErr, + "cred_insufficient": CredInsufficient, + "authinfo_unavail": AuthinfoUnavail, + "user_unknown": UserUnknown, + "maxtries": Maxtries, + "new_authtok_reqd": NewAuthtokReqd, + "acct_expired": AcctExpired, + "session_err": SessionErr, + "cred_unavail": CredUnavail, + "cred_expired": CredExpired, + "cred_err": CredErr, + "no_module_data": NoModuleData, + "conv_err": ConvErr, + "authtok_err": AuthtokErr, + "authtok_recover_err": AuthtokRecoveryErr, + "authtok_lock_busy": AuthtokLockBusy, + "authtok_disable_aging": AuthtokDisableAging, + "try_again": TryAgain, + "ignore": Success, /* Ignore can't be returned */ + "abort": Abort, + "authtok_expired": AuthtokExpired, + "module_unknown": ModuleUnknown, + "bad_item": BadItem, + "conv_again": ConvAgain, + "incomplete": Incomplete, + } + + type Action int + const ( + account Action = iota + 1 + auth + password + session + ) + actions := map[string]Action{ + "account": account, + "auth": auth, + "password": password, + "session": session, + } + + c := Credentials{} + + servicePath := t.TempDir() + + for ret, expected := range retTypes { + ret := ret + expected := expected + for actionName, action := range actions { + actionName := actionName + action := action + t.Run(fmt.Sprintf("%s %s", ret, actionName), func(t *testing.T) { + t.Parallel() + serviceName := ret + "-" + actionName + serviceFile := filepath.Join(servicePath, serviceName) + contents := fmt.Sprintf("%[1]s requisite pam_debug.so "+ + "auth=%[2]s cred=%[2]s acct=%[2]s prechauthtok=%[2]s "+ + "chauthtok=%[2]s open_session=%[2]s close_session=%[2]s\n"+ + "%[1]s requisite pam_permit.so\n", actionName, ret) + + if err := os.WriteFile(serviceFile, + []byte(contents), 0600); err != nil { + t.Fatalf("can't create service file %v: %v", serviceFile, err) + } + + tx, err := StartConfDir(serviceName, "user", c, servicePath) + if err != nil { + t.Fatalf("start #error: %v", err) + } + + switch action { + case account: + err = tx.AcctMgmt(0) + case auth: + err = tx.Authenticate(0) + case password: + err = tx.ChangeAuthTok(0) + case session: + err = tx.OpenSession(0) + } + + if tx.Status() != expected { + t.Fatalf("error #unexpected status %v", tx.Status()) + } + if tx.Status() == Success && err != nil { + t.Fatalf("error #unexpected: %v", err) + } else if tx.Status() != Success && err == nil { + t.Fatalf("error #expected an error message") + } + }) + } + } +} + func TestFailure_001(t *testing.T) { tx := Transaction{} _, err := tx.GetEnvList()