Skip to content

Commit 5302863

Browse files
authored
Merge pull request #1708 from pbiering/merge-pam-auth-from-v1
Merge pam auth from v1
2 parents 970d4ba + 6518f1b commit 5302863

File tree

6 files changed

+140
-1
lines changed

6 files changed

+140
-1
lines changed

DOCUMENTATION.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,10 @@ Available backends:
829829
`oauth2`
830830
: Use an OAuth2 server to authenticate users.
831831

832+
`pam`
833+
: Use local PAM to authenticate users.
834+
835+
832836
Default: `none`
833837

834838
##### cache_logins
@@ -1028,6 +1032,18 @@ OAuth2 token endpoint URL
10281032

10291033
Default:
10301034

1035+
##### pam_service
1036+
1037+
PAM service
1038+
1039+
Default: radicale
1040+
1041+
##### pam_group_membership
1042+
1043+
PAM group user should be member of
1044+
1045+
Default:
1046+
10311047
##### lc_username
10321048

10331049
Сonvert username to lowercase, must be true for case-insensitive auth

config

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
[auth]
6060

6161
# Authentication method
62-
# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | denyall
62+
# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall
6363
#type = none
6464

6565
# Cache logins for until expiration time
@@ -128,6 +128,12 @@
128128
# OAuth2 token endpoint URL
129129
#oauth2_token_endpoint = <URL>
130130

131+
# PAM service
132+
#pam_serivce = radicale
133+
134+
# PAM group user should be member of
135+
#pam_group_membership =
136+
131137
# Htpasswd filename
132138
#htpasswd_filename = /etc/radicale/users
133139

radicale/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"ldap",
4444
"imap",
4545
"oauth2",
46+
"pam",
4647
"dovecot")
4748

4849
CACHE_LOGIN_TYPES: Sequence[str] = (
@@ -51,6 +52,7 @@
5152
"htpasswd",
5253
"imap",
5354
"oauth2",
55+
"pam",
5456
)
5557

5658
AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")

radicale/auth/pam.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Radicale Server - Calendar Server
4+
# Copyright © 2011 Henry-Nicolas Tourneur
5+
# Copyright © 2021-2021 Unrud <unrud@outlook.com>
6+
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
7+
#
8+
# This library is free software: you can redistribute it and/or modify
9+
# it under the terms of the GNU General Public License as published by
10+
# the Free Software Foundation, either version 3 of the License, or
11+
# (at your option) any later version.
12+
#
13+
# This library is distributed in the hope that it will be useful,
14+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
# GNU General Public License for more details.
17+
#
18+
# You should have received a copy of the GNU General Public License
19+
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
20+
21+
"""
22+
PAM authentication.
23+
24+
Authentication using the ``pam-python`` module.
25+
26+
Important: radicale user need access to /etc/shadow by e.g.
27+
chgrp radicale /etc/shadow
28+
chmod g+r
29+
"""
30+
31+
import grp
32+
import pwd
33+
34+
from radicale import auth
35+
from radicale.log import logger
36+
37+
38+
class Auth(auth.BaseAuth):
39+
def __init__(self, configuration) -> None:
40+
super().__init__(configuration)
41+
try:
42+
import pam
43+
self.pam = pam
44+
except ImportError as e:
45+
raise RuntimeError("PAM authentication requires the Python pam module") from e
46+
self._service = configuration.get("auth", "pam_service")
47+
logger.info("auth.pam_service: %s" % self._service)
48+
self._group_membership = configuration.get("auth", "pam_group_membership")
49+
if (self._group_membership):
50+
logger.info("auth.pam_group_membership: %s" % self._group_membership)
51+
else:
52+
logger.info("auth.pam_group_membership: (empty, nothing to check / INSECURE)")
53+
54+
def pam_authenticate(self, *args, **kwargs):
55+
return self.pam.authenticate(*args, **kwargs)
56+
57+
def _login(self, login: str, password: str) -> str:
58+
"""Check if ``user``/``password`` couple is valid."""
59+
if login is None or password is None:
60+
return ""
61+
62+
# Check whether the user exists in the PAM system
63+
try:
64+
pwd.getpwnam(login).pw_uid
65+
except KeyError:
66+
logger.debug("PAM user not found: %r" % login)
67+
return ""
68+
else:
69+
logger.debug("PAM user found: %r" % login)
70+
71+
# Check whether the user has a primary group (mandatory)
72+
try:
73+
# Get user primary group
74+
primary_group = grp.getgrgid(pwd.getpwnam(login).pw_gid).gr_name
75+
logger.debug("PAM user %r has primary group: %r" % (login, primary_group))
76+
except KeyError:
77+
logger.debug("PAM user has no primary group: %r" % login)
78+
return ""
79+
80+
# Obtain supplementary groups
81+
members = []
82+
if (self._group_membership):
83+
try:
84+
members = grp.getgrnam(self._group_membership).gr_mem
85+
except KeyError:
86+
logger.debug(
87+
"PAM membership required group doesn't exist: %r" %
88+
self._group_membership)
89+
return ""
90+
91+
# Check whether the user belongs to the required group
92+
# (primary or supplementary)
93+
if (self._group_membership):
94+
if (primary_group != self._group_membership) and (login not in members):
95+
logger.warning("PAM user %r belongs not to the required group: %r" % (login, self._group_membership))
96+
return ""
97+
else:
98+
logger.debug("PAM user %r belongs to the required group: %r" % (login, self._group_membership))
99+
100+
# Check the password
101+
if self.pam_authenticate(login, password, service=self._service):
102+
return login
103+
else:
104+
logger.debug("PAM authentication not successful for user: %r (service %r)" % (login, self._service))
105+
return ""

radicale/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ def json_str(value: Any) -> dict:
311311
"value": "",
312312
"help": "OAuth2 token endpoint URL",
313313
"type": str}),
314+
("pam_group_membership", {
315+
"value": "",
316+
"help": "PAM group user should be member of",
317+
"type": str}),
318+
("pam_service", {
319+
"value": "radicale",
320+
"help": "PAM service",
321+
"type": str}),
314322
("strip_domain", {
315323
"value": "False",
316324
"help": "strip domain from username",

radicale/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
1919

2020
import ssl
21+
import sys
2122
from importlib import import_module, metadata
2223
from typing import Callable, Sequence, Type, TypeVar, Union
2324

@@ -55,6 +56,7 @@ def package_version(name):
5556

5657
def packages_version():
5758
versions = []
59+
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))
5860
for pkg in RADICALE_MODULES:
5961
versions.append("%s=%s" % (pkg, package_version(pkg)))
6062
return " ".join(versions)

0 commit comments

Comments
 (0)