diff --git a/MemberLifecycle.md b/MemberLifecycle.md index 397a790..0d96ed6 100644 --- a/MemberLifecycle.md +++ b/MemberLifecycle.md @@ -17,6 +17,25 @@ The requirements to vogelnest in this process are: - notify ppl to activate the user - invite the user to choose a password - reflect the newly choosen username back to CiviCRM; CiviCRM will save the mail adress in the SOG domain + +### Technical Documentation + +CiviCRM drops a request to the endpoint *create_user* using POST. +The following info shall be delivered in a JSON body: + - First Name + - Last Name + - E-Mail + - Lokalgruppe (prefixed lowercase, like lg_aachen or lg_berlin) + +Since only Civi may create users, the request has to be authenticated +by HTTP basic auth. +Username: *civicrm* +Password: As specified in CIVICRM_SECRET + +This app will generate a username and return it as plain text +in the response body. + +This app will issue error 500 if user creation is not possible. ## Other requirements in the member lifecycle - Changes in the alternative e mail adress shall be reflected (see #28) diff --git a/app.py b/app.py index 1782b0e..9d7ef3b 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ from ldap3.utils import conv from middleware import middleware import token_handler +import group_request_handler from os.path import join from urllib.parse import unquote import jwt @@ -54,6 +55,42 @@ def login(): except Exception as e: abort(403) +@app.route('/create_user', methods=['POST']) +def create_user(): + # Handle Auth + if request.authorization["username"] != "civicrm" or request.authorization["password"] != config.CIVICRM_SECRET: + abort(403), "Invalid credentials" + firstName = sanitize(request.json.get('firstName')) + lastName = sanitize(request.json.get('lastName')) + email = sanitize(request.json.get('email')) + lokalgruppe = sanitize(request.json.get('lokalgruppe')) #like lg_aachen + + # Abort 500 if lokalgruppe does not exist + try: + api.get_group(lokalgruppe) + except: + abort(500, "lokalgruppe " + lokalgruppe + " does not exist") + + try: + username = api.create_member(firstName, lastName,email) + + # Ask member to set password + password_reset_token = token_handler.create_initial_confirmation_jwt_token(username, email).decode("utf-8") + mail.send_email(email, "Willkommen bei SOG!", "emails/new_user_onboarding", { + "firstName" : firstName, + "name": username, + "link": join(config.FRONTEND_URL, "confirm?key=" + password_reset_token), + }) + + # request membership in LG. Is non existent LG be dealt with correctly? + group_request_handler.request_inactive_pending(api, lokalgruppe, username) + # Person becomes member of allgemein upon activation + + return username + except Exception as e: + print(e) + abort(500) + @app.route('/inactive_info', methods=['GET']) def inactive_info(): uid = token_handler.get_jwt_user(request.headers.get('Authorization')) @@ -446,7 +483,7 @@ def add_guest_to_group(group_id): api.add_group_member(group_id, uid) group = api.get_group(group_id) - mail.send_email(str(owner.mail), "Du bist jetzt im Verteiler " + str(group.cn), \ + mail.send_email(str(mail), "Du bist jetzt im Verteiler " + str(group.cn), \ "emails/guest_invite_email", { "name": str(name), "group_name": str(group.cn), @@ -461,17 +498,7 @@ def request_access_to_group(group_id): return abort(401) if not api.is_active(my_uid): return abort(401) - api.add_group_active_pending_member(group_id, my_uid) - group = api.get_group(group_id) - user = api.get_user(my_uid) - for owner in api.get_group_owners(group_id): - mail.send_email(str(owner.mail), "Neue Anfrage in " + str(group.cn), \ - "emails/new_pending_member_mail", { - "name": str(owner.cn), - "group_name": str(group.cn), - "dashboard_url": config.FRONTEND_URL, - "new_member_name": str(user.cn) - }) + group_request_handler.request_active_pending(api, group_id, my_uid) return "ok" @app.route('/groups//accept_pending_member', methods=['POST']) diff --git a/config.sample.py b/config.sample.py index e4f218a..d6da84d 100644 --- a/config.sample.py +++ b/config.sample.py @@ -27,3 +27,5 @@ MAIL_SERVER = '' MAIL_PASSWORD = '' +# Token used by civi to authenticate its requests +CIVICRM_SECRET = '' \ No newline at end of file diff --git a/emails/new_lg_member.html b/emails/new_lg_member.html new file mode 100644 index 0000000..d985a23 --- /dev/null +++ b/emails/new_lg_member.html @@ -0,0 +1,8 @@ +

Hallo,

+ +

Soeben hat sich {new_member_name} für Deine Lokalgruppe {group_name} angemeldet. +Das neue Mitglied ist schon auf dem Lokalgruppen-Verteiler eingetragen und hat einen Account erhalten.

+

Achtung: Der SOG-Account des Mitglieds muss erst von dir aktiviert werden.

+

Bitte bestätige am Besten jetzt direkt im Vogelnest, dass {new_member_name} tatsächlich in eurer LG aktiv ist!

+

Viele Grüße,

+

Das SOG Vogelnest

\ No newline at end of file diff --git a/emails/new_lg_member.txt b/emails/new_lg_member.txt new file mode 100644 index 0000000..fece0b3 --- /dev/null +++ b/emails/new_lg_member.txt @@ -0,0 +1,13 @@ +Hallo, + +Soeben hat sich {new_member_name} für Deine Lokalgruppe {group_name} angemeldet. +Das neue Mitglied ist schon auf dem Lokalgruppen-Verteiler eingetragen und hat einen Account erhalten. +Achtung: Der SOG-Account des Mitglieds muss erst von dir aktiviert werden. + +Bitte bestätige am Besten jetzt direkt im Vogelnest, dass {new_member_name} tatsächlich in eurer LG aktiv ist: + +{dashboard_url} + +Viele Grüße, + +Das SOG Vogelnest \ No newline at end of file diff --git a/emails/new_user_onboarding.html b/emails/new_user_onboarding.html new file mode 100644 index 0000000..d2ad665 --- /dev/null +++ b/emails/new_user_onboarding.html @@ -0,0 +1,12 @@ +

Hallo {firstName},

+

Wir freuen uns sehr, dich als neues Mitglied bei Studieren Ohne Grenzen begrüßen zu dürfen.

+

Damit du direkt einsteigen und mitarbeiten kannst, haben wir dir automatisch einen Zugang für unsere Online-Plattformen erstellt. Über diese Plattformen tauschen wir wichtige Nachrichten, Informationen und Dateien aus und diskutieren auch Lokalgruppen-übergreifend.

+

Benutzername ist: {name}

+

Bitte öffne folgenden Link, um deine Mailadresse zu bestätigen und ein Passwort zu vergeben: +Account bestätigen

+

Dein Account wird freigeschaltet, sobald dein Lokalkoordinator:in bestätigt hat, dass du tatsächlich bei Studieren Ohne Grenzen aktiv bist. +Mit deinem Benutzernamen und dem von dir vergebenen Passwort kannst du dich auf allen SOG-Systeme einloggen.

+ +

Eine Übersicht deiner Daten und Gruppen gibt dir das Vogelnest: vogelnest.studieren-ohne-grenzen.org

+

Viele Grüße,

+

Das SOG Vogelnest

\ No newline at end of file diff --git a/emails/new_user_onboarding.txt b/emails/new_user_onboarding.txt new file mode 100644 index 0000000..91bf1ca --- /dev/null +++ b/emails/new_user_onboarding.txt @@ -0,0 +1,18 @@ +Hallo {firstName}, + +Wir freuen uns sehr, dich als neues Mitglied bei Studieren Ohne Grenzen begrüßen zu dürfen. + +Damit du direkt einsteigen und mitarbeiten kannst, haben wir dir automatisch einen Zugang für unsere Online-Plattformen erstellt. +Über diese Plattformen tauschen wir wichtige Nachrichten, Informationen und Dateien aus und diskutieren auch Lokalgruppen-übergreifend. + +Dein Benutzername ist: {name} +Bitte öffne folgenden Link, um deine Mailadresse zu bestätigen und ein Passwort zu vergeben: +{link} + +Dein Account wird freigeschaltet, sobald dein Lokalkoordinator:in bestätigt hat, dass du tatsächlich bei Studieren Ohne Grenzen aktiv bist. + +Mit deinem Benutzernamen und dem von dir vergebenen Passwort kannst du dich auf allen SOG-Systeme einloggen. + +Eine Übersicht deiner Daten und Gruppen gibt dir das Vogelnest: vogelnest.studieren-ohne-grenzen.org +Viele Grüße, +Das SOG Vogelnest diff --git a/group_request_handler.py b/group_request_handler.py new file mode 100644 index 0000000..3954cd1 --- /dev/null +++ b/group_request_handler.py @@ -0,0 +1,27 @@ +import mail +import config + +def request_active_pending(api, group_id, my_uid): + api.add_group_active_pending_member(group_id, my_uid) + group = api.get_group(group_id) + user = api.get_user(my_uid) + for owner in api.get_group_owners(group_id): + mail.send_email(str(owner.mail), "Neue Anfrage in " + str(group.cn), \ + "emails/new_pending_member_mail", { + "name": str(owner.cn), + "group_name": str(group.cn), + "dashboard_url": config.FRONTEND_URL, + "new_member_name": str(user.cn) + }) + +def request_inactive_pending(api, group_id, my_uid): + api.add_group_inactive_pending_member(group_id, my_uid) + group = api.get_group(group_id) + user = api.get_inactive_user(my_uid) + for owner in api.get_group_owners(group_id): + mail.send_email(str(owner.mail), "Neue Anfrage in " + str(group.cn), \ + "emails/new_lg_member", { + "group_name": str(group.cn), + "dashboard_url": config.FRONTEND_URL, + "new_member_name": str(user.cn) + }) \ No newline at end of file diff --git a/ldap_api.py b/ldap_api.py index bc0ac37..522fdaf 100644 --- a/ldap_api.py +++ b/ldap_api.py @@ -148,6 +148,39 @@ def create_guest(self, name, mail): raise RuntimeError(self.conn.result) return uid + # Creates new (inactive) member + def create_member(self, firstName, lastName, mail): + username = self.generate_username("%s.%s" % (firstName, lastName)) + dn = self.get_inactive_person_dn(username) + sog_mail = username + "@studieren-ohne-grenzen.org" + sog_alias = username + "@s-o-g.org" + self.conn.add(dn, [ + 'person', + 'sogperson', + 'organizationalPerson', + 'inetOrgPerson', + 'top', + 'PostfixBookMailAccount', + 'PostfixBookMailForward' + ], { + 'uid': username, + 'cn': '%s %s' % (firstName, lastName), + 'displayName': firstName, + 'givenName': firstName, + 'sn': lastName, + 'mail': sog_mail, + 'mailAlias': sog_alias, + 'mail-alternative': mail, + 'mailHomeDirectory': '/srv/vmail/%s' % sog_mail, + 'mailStorageDirectory': 'maildir:/srv/vmail/%s/Maildir' % sog_mail, + 'mailEnabled': 'TRUE', + 'mailGidNumber': 5000, + 'mailUidNumber': 5000 + }) + if self.conn.result['result'] != 0: + raise RuntimeError(self.conn.result) + return username + def activate_user(self, uid): old_dn = self.get_inactive_person_dn(uid) pending_groups = self.get_groups_as_inactive_pending_member(uid) @@ -222,6 +255,11 @@ def get_groups_as_inactive_pending_member(self, uid): self.conn.search(self.config.DN_GROUPS, '(&(objectClass=groupOfNames)(pending=%s))' % user_dn, attributes=GROUP_ATTRIBUTES_PENDING) return self.conn.entries + def add_group_inactive_pending_member(self, group, uid): + group_dn = self.get_group_dn(group) + user_dn = self.find_inactive_user_dn(uid) + self.conn.modify(group_dn, {'pending': [(MODIFY_ADD, [user_dn])]}) + def add_group_active_pending_member(self, group, uid): group_dn = self.get_group_dn(group) user_dn = self.find_user_dn(uid) diff --git a/middleware.py b/middleware.py index 84dcd85..975f155 100644 --- a/middleware.py +++ b/middleware.py @@ -14,9 +14,8 @@ def __call__(self, environ, start_response): authheader = request.headers.get('Authorization') uid = token_handler.get_jwt_user(authheader) - print(request.url.replace(request.url_root, "")) if uid == None and \ - not request.url.replace(request.url_root, "") in ["login", "users/reset_password", "users/set_password_with_key"] and \ + not request.url.replace(request.url_root, "") in ["login", "users/reset_password", "users/set_password_with_key", "create_user"] and \ not request.url.replace(request.url_root, "").startswith("confirm"): res = Response(u'Authorization failed', mimetype= 'text/plain', status=401) return res(environ, start_response) diff --git a/token_handler.py b/token_handler.py index 5d30b18..49b973a 100644 --- a/token_handler.py +++ b/token_handler.py @@ -24,6 +24,14 @@ def create_password_reset_jwt_token(username): 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30) }, config.JWT_SECRET, algorithm='HS256') +def create_initial_confirmation_jwt_token(username, email): + return jwt.encode({ + 'type': 'initial_confirmation', + 'username': username, + 'email': email, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30) + }, config.JWT_SECRET, algorithm='HS256') + # checks header and returns username if header is valid # returns None if header is invalid def get_jwt_user(header):