Skip to content

Commit 87cb645

Browse files
authored
feat: password warning
NethServer/dev#7229
2 parents c32c5b4 + d3cf81d commit 87cb645

File tree

21 files changed

+1147
-12
lines changed

21 files changed

+1147
-12
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Password expiration
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<html>
2+
<head>
3+
<meta charset="UTF-8">
4+
<title>Password expiration notice</title>
5+
<style>
6+
body {
7+
font-family: Arial, sans-serif;
8+
background-color: #f4f4f4;
9+
margin: 0;
10+
padding: 0;
11+
display: flex;
12+
justify-content: center;
13+
align-items: center;
14+
height: 100vh;
15+
}
16+
.container {
17+
width: 100%;
18+
max-width: 600px;
19+
padding: 4rem 2.5rem;
20+
border-radius: 0.75rem;
21+
background: #FFF;
22+
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.06), 0px 4px 6px 0px rgba(0, 0, 0, 0.10);
23+
text-align: center;
24+
}
25+
.header {
26+
padding: 1.5rem 1rem;
27+
border-radius: 0.375rem;
28+
background: #F3F4F6;
29+
}
30+
.button {
31+
display: inline-block;
32+
padding: 0.5rem 0.75rem;
33+
border-radius: 0.375rem;
34+
background: #1D4ED8;
35+
color: white;
36+
text-decoration: none;
37+
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
38+
transition: background-color 0.3s ease;
39+
margin-top: 1rem;
40+
}
41+
.button:hover {
42+
background: #1E40AF;
43+
}
44+
.footer {
45+
font-size: 12px;
46+
color: #4B5563;
47+
margin-top: 1.5rem;
48+
}
49+
</style>
50+
</head>
51+
<body>
52+
<div class="container">
53+
<div class="header">
54+
<h1>Password expiration notice</h1>
55+
</div>
56+
<p>Dear $name ($user),</p>
57+
<p>your password is expiring in <strong>$days days</strong>. To ensure the security of your account, please change your password before it expires.</p>
58+
<a href="$portal_url" class="button">Change password</a>
59+
<p class="footer">For assistance or further inquiries, please reach out to your administrator.</p>
60+
</div>
61+
</body>
62+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Scadenza password
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<html>
2+
<head>
3+
<meta charset="UTF-8">
4+
<title>Avviso di scadenza password</title>
5+
<style>
6+
body {
7+
font-family: Arial, sans-serif;
8+
background-color: #f4f4f4;
9+
margin: 0;
10+
padding: 0;
11+
display: flex;
12+
justify-content: center;
13+
align-items: center;
14+
height: 100vh;
15+
}
16+
.container {
17+
width: 100%;
18+
max-width: 600px;
19+
padding: 4rem 2.5rem;
20+
border-radius: 0.75rem;
21+
background: #FFF;
22+
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.06), 0px 4px 6px 0px rgba(0, 0, 0, 0.10);
23+
text-align: center;
24+
}
25+
.header {
26+
padding: 1.5rem 1rem;
27+
border-radius: 0.375rem;
28+
background: #F3F4F6;
29+
}
30+
.button {
31+
display: inline-block;
32+
padding: 0.5rem 0.75rem;
33+
border-radius: 0.375rem;
34+
background: #1D4ED8;
35+
color: white;
36+
text-decoration: none;
37+
box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
38+
transition: background-color 0.3s ease;
39+
margin-top: 1rem;
40+
}
41+
.button:hover {
42+
background: #1E40AF;
43+
}
44+
.footer {
45+
font-size: 12px;
46+
color: #4B5563;
47+
margin-top: 1.5rem;
48+
}
49+
</style>
50+
</head>
51+
<body>
52+
<div class="container">
53+
<div class="header">
54+
<h1>Avviso di scadenza password</h1>
55+
</div>
56+
<p>Gentile $name ($user),</p>
57+
<p>la tua password scadrà tra <strong>$days giorni</strong>. Per garantire la sicurezza del tuo account, ti invitiamo a cambiarla prima della scadenza.</p>
58+
<a href="$portal_url" class="button">Cambia password</a>
59+
<p class="footer">Per assistenza o ulteriori informazioni, contatta l'amministratore.</p>
60+
</div>
61+
</body>
62+
</html>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[Unit]
2+
Description=Domain user password notification
3+
4+
[Service]
5+
Type=oneshot
6+
ExecStart=runagent -m cluster notify-password-warning
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[Unit]
2+
Description=Domain user password notification trigger
3+
4+
[Timer]
5+
OnCalendar=daily
6+
Persistent=true
7+
RandomizedDelaySec=3600
8+
9+
[Install]
10+
WantedBy=timers.target
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
3+
#
4+
# Copyright (C) 2025 Nethesis S.r.l.
5+
# SPDX-License-Identifier: GPL-3.0-or-later
6+
#
7+
8+
# This script reads a mail from stdin and sends it using Python's smtplib
9+
# It takes as arguments the recipient
10+
11+
import re
12+
import ssl
13+
import sys
14+
import agent
15+
import smtplib
16+
import argparse
17+
from email.mime.text import MIMEText
18+
from email.mime.multipart import MIMEMultipart
19+
20+
def get_content_type(body):
21+
"""
22+
Determine if the input string is HTML or plain text.
23+
24+
Args:
25+
input_string (str): The string to analyze.
26+
27+
Returns:
28+
str: 'html' if the string is HTML, 'plain' otherwise.
29+
"""
30+
# Define a simple regex pattern to detect HTML tags
31+
html_pattern = re.compile(r'<[^>]+>')
32+
33+
# Search for HTML tags in the input string
34+
if re.search(html_pattern, body):
35+
return 'html'
36+
else:
37+
return 'plain'
38+
39+
40+
def main():
41+
"""
42+
Main function to send an email using cluster notification settings.
43+
This function parses command-line arguments to get email recipients, subject, and from address.
44+
It reads the email body from stdin and retrieves SMTP configuration from a Redis database.
45+
Depending on the SMTP configuration, it sets up an appropriate connection (plain, STARTTLS, or SSL/TLS),
46+
authenticates if necessary, and sends the email.
47+
Command-line arguments:
48+
- recipients: List of email recipients.
49+
- -s, --subject: Subject of the email.
50+
- -f, --from: From address of the email.
51+
Raises:
52+
- SystemExit: If no recipients are provided or if the smarthost is not enabled.
53+
"""
54+
# Accept the following arguments
55+
# argv[1] to argv[n] = recipient
56+
# -s = subject
57+
# -f = from
58+
59+
# Parse the arguments
60+
parser = argparse.ArgumentParser(description='Send an email using cluster notification settings.')
61+
parser.add_argument('recipients', metavar='R', type=str, nargs='*', help='Email recipients')
62+
parser.add_argument('-s', '--subject', type=str, default='', help='Email subject')
63+
parser.add_argument('-f', '--from', dest='from_addr', type=str, default=f'no-reply@{agent.get_hostname()}', help='From address')
64+
65+
args = parser.parse_args()
66+
67+
# Check if recipients list is empty
68+
if not args.recipients:
69+
print("Error: At least one recipient is required.", file=sys.stderr)
70+
sys.exit(1)
71+
72+
# Read mail body from stdin
73+
body = sys.stdin.read()
74+
75+
# Read SMTP cluser configuration
76+
rdb = agent.redis_connect(use_replica=True)
77+
smtp_config = agent.get_smarthost_settings(rdb)
78+
if not smtp_config['enabled']:
79+
print("Error: Smarthost is not enabled.", file=sys.stderr)
80+
sys.exit(1)
81+
82+
# Create a SSL context
83+
ctx = ssl.create_default_context()
84+
if not smtp_config['tls_verify']:
85+
ctx.check_hostname = False
86+
ctx.verify_mode = ssl.CERT_NONE
87+
88+
# Setup connection based on encrypt_smtp value.
89+
# Possible values: none, starttls, tls
90+
if smtp_config['encrypt_smtp'] == 'tls':
91+
smtp = smtplib.SMTP_SSL(smtp_config['host'], smtp_config['port'], context=ctx)
92+
elif smtp_config['encrypt_smtp'] == 'starttls':
93+
smtp = smtplib.SMTP(smtp_config['host'], smtp_config['port'])
94+
smtp.starttls(context=ctx)
95+
else:
96+
smtp = smtplib.SMTP(smtp_config['host'], smtp_config['port'])
97+
98+
# Authenticate if needed
99+
if smtp_config['username'] and smtp_config['password']:
100+
smtp.login(smtp_config['username'], smtp_config['password'])
101+
102+
# Create a multipart message and set headers
103+
message = MIMEMultipart()
104+
message["From"] = args.from_addr
105+
message["To"] = ','.join(args.recipients)
106+
message["Subject"] = args.subject
107+
108+
# Attach the HTML part
109+
type = get_content_type(body)
110+
message.attach(MIMEText(body, type))
111+
112+
# Send the message via the configured SMTP server
113+
smtp.sendmail(args.from_addr, ','.join(args.recipients), message.as_string())
114+
smtp.quit()
115+
116+
117+
main()

core/imageroot/usr/local/agent/pypkg/agent/ldapclient/ad.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# along with NethServer. If not, see COPYING.
1919
#
2020

21+
from datetime import timedelta
22+
import datetime
2123
import ldap3
2224
from .exceptions import LdapclientEntryNotFound
2325
from .base import LdapclientBase
@@ -31,6 +33,16 @@ def _get_dn_attributes(self, dn, lfilter='(objectClass=*)', attributes=[ldap3.AL
3133
return entry['attributes']
3234

3335
raise LdapclientEntryNotFound()
36+
37+
def get_max_pwd_age(self):
38+
response = self.ldapconn.search(self.base_dn, '(objectClass=domainDNS)', attributes=['maxPwdAge'])
39+
if response[0]:
40+
result = [entry for entry in response[2] if entry['type'] == 'searchResEntry']
41+
else:
42+
return None
43+
if not result:
44+
return None
45+
return result[0]['attributes']['maxPwdAge']
3446

3547
def get_group(self, group):
3648
# Escape group string to build the filter assertion:
@@ -121,12 +133,15 @@ def get_user_entry(self, user, lextra_attributes=[]):
121133

122134
raise LdapclientEntryNotFound()
123135

124-
def list_users(self):
136+
def list_users(self, extra_info=False):
137+
attributes = ['displayName', 'sAMAccountName', 'userAccountControl']
138+
if extra_info:
139+
attributes += ['whenCreated', 'pwdLastSet', 'mail']
125140
user_entry_generator = self.ldapconn.extend.standard.paged_search(
126141
search_base = self.base_dn,
127142
search_filter = f'(&(objectClass=user)(objectCategory=person){self._get_users_search_filter_clause()})',
128143
search_scope = ldap3.SUBTREE,
129-
attributes = ['displayName', 'sAMAccountName', 'userAccountControl'],
144+
attributes = attributes,
130145
paged_size = 900,
131146
generator=True,
132147
)
@@ -135,9 +150,23 @@ def list_users(self):
135150
for entry in user_entry_generator:
136151
if entry['type'] != 'searchResEntry':
137152
continue # ignore referrals
138-
users.append({
153+
user = {
139154
"user": entry['attributes']['sAMAccountName'],
140155
"display_name": entry['attributes'].get('displayName') or "",
141156
"locked": bool(entry['attributes']['userAccountControl'] & 0x2), # ACCOUNTDISABLE
142-
})
157+
}
158+
if extra_info:
159+
pwd_changed_time = entry['attributes'].get('pwdLastSet', entry['attributes'].get('whenCreated', None))
160+
if self.get_max_pwd_age().total_seconds() >= 86400000000000:
161+
# Password aging is disabled
162+
user['expired'] = False
163+
user['password_expiration'] = -1
164+
else:
165+
expiry_date = pwd_changed_time + timedelta(seconds=self.get_max_pwd_age().total_seconds())
166+
user['expired'] = datetime.datetime.now(datetime.timezone.utc) > expiry_date
167+
user['password_expiration'] = int(expiry_date.timestamp())
168+
user["mail"] = entry['attributes'].get('mail') if entry['attributes'].get('mail') else ""
169+
170+
users.append(user)
171+
143172
return users

0 commit comments

Comments
 (0)