halkyon_base: a Serverpod module for sign-in, accounts and instance basics
What every Halkyon server has, taken from Halkyon Learn's: anonymous, GitHub and email-code sign-in with their endpoints, an account's ways in and its deletion, the pruning of abandoned anonymous people, of sign-up attempts and of expired codes, and the instance's basics (the sign-in ways offered, the source link, the minimum app build, where people's data and its backups are). Endpoints sit under the nickname `base`. A host configures it in one place before its pod starts: its name for the mails, its source link, its minimum build, and hooks that delete its own data with a person. The tables keep Learn's names, columns and indexes, so a host moving onto the module migrates nothing but version rows. Server package under the SSPL, client under the AGPL, as Learn's are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
80fa7b9628
commit
af1de799f4
68 changed files with 17704 additions and 3 deletions
19
packages/halkyon_base_server/.gitignore
vendored
Normal file
19
packages/halkyon_base_server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Files and directories created by pub
|
||||
.dart_tool/
|
||||
.packages
|
||||
|
||||
# Omit committing pubspec.lock for library packages:
|
||||
# https://dart.dev/guides/libraries/private-files#pubspeclock
|
||||
pubspec.lock
|
||||
|
||||
# Conventional directory for build outputs
|
||||
build/
|
||||
|
||||
# Directory created by dartdoc
|
||||
doc/api/
|
||||
|
||||
# Passwords file: the tests' throwaway values, never committed.
|
||||
config/passwords.yaml
|
||||
|
||||
# Embedded PostgreSQL runtime artifacts (data dir, sockets, pidfiles)
|
||||
.serverpod/
|
||||
8
packages/halkyon_base_server/CHANGELOG.md
Normal file
8
packages/halkyon_base_server/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
## 0.1.0
|
||||
|
||||
* What every Halkyon server has, from Halkyon Learn's server: anonymous,
|
||||
GitHub and email code sign-in with their endpoints, the account's ways in
|
||||
and its deletion (with the host's own deletions, `HalkyonBase.deletePersonData`),
|
||||
the pruning of abandoned anonymous people, of sign-up attempts and of
|
||||
expired codes, and the instance's basics (`instance.basics()`). The tables
|
||||
`email_identity` and `email_code_request` are as Halkyon Learn has them.
|
||||
557
packages/halkyon_base_server/LICENSE
Normal file
557
packages/halkyon_base_server/LICENSE
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
Server Side Public License
|
||||
VERSION 1, OCTOBER 16, 2018
|
||||
|
||||
Copyright © 2018 MongoDB, Inc.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to Server Side Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as “you”. “Licensees” and
|
||||
“recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in
|
||||
a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a “modified version” of the
|
||||
earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on
|
||||
the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through a
|
||||
computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the
|
||||
extent that it includes a convenient and prominently visible feature that
|
||||
(1) displays an appropriate copyright notice, and (2) tells the user that
|
||||
there is no warranty for the work (except to the extent that warranties
|
||||
are provided), that licensees may convey the work under this License, and
|
||||
how to view a copy of this License. If the interface presents a list of
|
||||
user commands or options, such as a menu, a prominent item in the list
|
||||
meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The “source code” for a work means the preferred form of the work for
|
||||
making modifications to it. “Object code” means any non-source form of a
|
||||
work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that is
|
||||
widely used among developers working in that language. The “System
|
||||
Libraries” of an executable work include anything, other than the work as
|
||||
a whole, that (a) is included in the normal form of packaging a Major
|
||||
Component, but which is not part of that Major Component, and (b) serves
|
||||
only to enable use of the work with that Major Component, or to implement
|
||||
a Standard Interface for which an implementation is available to the
|
||||
public in source code form. A “Major Component”, in this context, means a
|
||||
major essential component (kernel, window system, and so on) of the
|
||||
specific operating system (if any) on which the executable work runs, or
|
||||
a compiler used to produce the work, or an object code interpreter used
|
||||
to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the
|
||||
source code needed to generate, install, and (for an executable work) run
|
||||
the object code and to modify the work, including scripts to control
|
||||
those activities. However, it does not include the work's System
|
||||
Libraries, or general-purpose tools or generally available free programs
|
||||
which are used unmodified in performing those activities but which are
|
||||
not part of the work. For example, Corresponding Source includes
|
||||
interface definition files associated with source files for the work, and
|
||||
the source code for shared libraries and dynamically linked subprograms
|
||||
that the work is specifically designed to require, such as by intimate
|
||||
data communication or control flow between those subprograms and other
|
||||
parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program, subject to section 13. The
|
||||
output from running a covered work is covered by this License only if the
|
||||
output, given its content, constitutes a covered work. This License
|
||||
acknowledges your rights of fair use or other equivalent, as provided by
|
||||
copyright law. Subject to section 13, you may make, run and propagate
|
||||
covered works that you do not convey, without conditions so long as your
|
||||
license otherwise remains in force. You may convey covered works to
|
||||
others for the sole purpose of having them make modifications exclusively
|
||||
for you, or provide you with facilities for running those works, provided
|
||||
that you comply with the terms of this License in conveying all
|
||||
material for which you do not control copyright. Those thus making or
|
||||
running the covered works for you must do so exclusively on your
|
||||
behalf, under your direction and control, on terms that prohibit them
|
||||
from making any copies of your copyrighted material outside their
|
||||
relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes it
|
||||
unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article 11
|
||||
of the WIPO copyright treaty adopted on 20 December 1996, or similar laws
|
||||
prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention is
|
||||
effected by exercising rights under this License with respect to the
|
||||
covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's users,
|
||||
your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice; keep
|
||||
intact all notices stating that this License and any non-permissive terms
|
||||
added in accord with section 7 apply to the code; keep intact all notices
|
||||
of the absence of any warranty; and give all recipients a copy of this
|
||||
License along with the Program. You may charge any price or no price for
|
||||
each copy that you convey, and you may offer support or warranty
|
||||
protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the terms
|
||||
of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it,
|
||||
and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released
|
||||
under this License and any conditions added under section 7. This
|
||||
requirement modifies the requirement in section 4 to “keep intact all
|
||||
notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to
|
||||
anyone who comes into possession of a copy. This License will therefore
|
||||
apply, along with any applicable section 7 additional terms, to the
|
||||
whole of the work, and all its parts, regardless of how they are
|
||||
packaged. This License gives no permission to license the work in any
|
||||
other way, but it does not invalidate such permission if you have
|
||||
separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your work
|
||||
need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work, and
|
||||
which are not combined with it such as to form a larger program, in or on
|
||||
a volume of a storage or distribution medium, is called an “aggregate” if
|
||||
the compilation and its resulting copyright are not used to limit the
|
||||
access or legal rights of the compilation's users beyond what the
|
||||
individual works permit. Inclusion of a covered work in an aggregate does
|
||||
not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium customarily
|
||||
used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a written
|
||||
offer, valid for at least three years and valid for as long as you
|
||||
offer spare parts or customer support for that product model, to give
|
||||
anyone who possesses the object code either (1) a copy of the
|
||||
Corresponding Source for all the software in the product that is
|
||||
covered by this License, on a durable physical medium customarily used
|
||||
for software interchange, for a price no more than your reasonable cost
|
||||
of physically performing this conveying of source, or (2) access to
|
||||
copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This alternative is
|
||||
allowed only occasionally and noncommercially, and only if you received
|
||||
the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place
|
||||
(gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to copy
|
||||
the object code is a network server, the Corresponding Source may be on
|
||||
a different server (operated by you or a third party) that supports
|
||||
equivalent copying facilities, provided you maintain clear directions
|
||||
next to the object code saying where to find the Corresponding Source.
|
||||
Regardless of what server hosts the Corresponding Source, you remain
|
||||
obligated to ensure that it is available for as long as needed to
|
||||
satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you
|
||||
inform other peers where the object code and Corresponding Source of
|
||||
the work are being offered to the general public at no charge under
|
||||
subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be included
|
||||
in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, “normally used” refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as part
|
||||
of a transaction in which the right of possession and use of the User
|
||||
Product is transferred to the recipient in perpetuity or for a fixed term
|
||||
(regardless of how the transaction is characterized), the Corresponding
|
||||
Source conveyed under this section must be accompanied by the
|
||||
Installation Information. But this requirement does not apply if neither
|
||||
you nor any third party retains the ability to install modified object
|
||||
code on the User Product (for example, the work has been installed in
|
||||
ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access
|
||||
to a network may be denied when the modification itself materially
|
||||
and adversely affects the operation of the network or violates the
|
||||
rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in
|
||||
accord with this section must be in a format that is publicly documented
|
||||
(and with an implementation available to the public in source code form),
|
||||
and must require no special password or key for unpacking, reading or
|
||||
copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
“Additional permissions” are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall be
|
||||
treated as though they were included in this License, to the extent that
|
||||
they are valid under applicable law. If additional permissions apply only
|
||||
to part of the Program, that part may be used separately under those
|
||||
permissions, but the entire Program remains governed by this License
|
||||
without regard to the additional permissions. When you convey a copy of
|
||||
a covered work, you may at your option remove any additional permissions
|
||||
from that copy, or from any part of it. (Additional permissions may be
|
||||
written to require their own removal in certain cases when you modify the
|
||||
work.) You may place additional permissions on material, added by you to
|
||||
a covered work, for which you have or can give appropriate copyright
|
||||
permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add
|
||||
to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade
|
||||
names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material
|
||||
by anyone who conveys the material (or modified versions of it) with
|
||||
contractual assumptions of liability to the recipient, for any
|
||||
liability that these contractual assumptions directly impose on those
|
||||
licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further
|
||||
restrictions” within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further restriction,
|
||||
you may remove that term. If a license document contains a further
|
||||
restriction but permits relicensing or conveying under this License, you
|
||||
may add to a covered work material governed by the terms of that license
|
||||
document, provided that the further restriction does not survive such
|
||||
relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must
|
||||
place, in the relevant source files, a statement of the additional terms
|
||||
that apply to those files, or a notice indicating where to find the
|
||||
applicable terms. Additional terms, permissive or non-permissive, may be
|
||||
stated in the form of a separately written license, or stated as
|
||||
exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or modify
|
||||
it is void, and will automatically terminate your rights under this
|
||||
License (including any patent licenses granted under the third paragraph
|
||||
of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally terminates
|
||||
your license, and (b) permanently, if the copyright holder fails to
|
||||
notify you of the violation by some reasonable means prior to 60 days
|
||||
after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated
|
||||
permanently if the copyright holder notifies you of the violation by some
|
||||
reasonable means, this is the first time you have received notice of
|
||||
violation of this License (for any work) from that copyright holder, and
|
||||
you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run a
|
||||
copy of the Program. Ancillary propagation of a covered work occurring
|
||||
solely as a consequence of using peer-to-peer transmission to receive a
|
||||
copy likewise does not require acceptance. However, nothing other than
|
||||
this License grants you permission to propagate or modify any covered
|
||||
work. These actions infringe copyright if you do not accept this License.
|
||||
Therefore, by modifying or propagating a covered work, you indicate your
|
||||
acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically receives
|
||||
a license from the original licensors, to run, modify and propagate that
|
||||
work, subject to this License. You are not responsible for enforcing
|
||||
compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered work
|
||||
results from an entity transaction, each party to that transaction who
|
||||
receives a copy of the work also receives whatever licenses to the work
|
||||
the party's predecessor in interest had or could give under the previous
|
||||
paragraph, plus a right to possession of the Corresponding Source of the
|
||||
work from the predecessor in interest, if the predecessor has it or can
|
||||
get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights
|
||||
granted or affirmed under this License. For example, you may not impose a
|
||||
license fee, royalty, or other charge for exercise of rights granted
|
||||
under this License, and you may not initiate litigation (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that any patent claim
|
||||
is infringed by making, using, selling, offering for sale, or importing
|
||||
the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A “contributor” is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The work
|
||||
thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or
|
||||
controlled by the contributor, whether already acquired or hereafter
|
||||
acquired, that would be infringed by some manner, permitted by this
|
||||
License, of making, using, or selling its contributor version, but do not
|
||||
include claims that would be infringed only as a consequence of further
|
||||
modification of the contributor version. For purposes of this definition,
|
||||
“control” includes the right to grant patent sublicenses in a manner
|
||||
consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to make,
|
||||
use, sell, offer for sale, import and otherwise run, modify and propagate
|
||||
the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To “grant” such a patent license to a party
|
||||
means to make such an agreement or commitment not to enforce a patent
|
||||
against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and
|
||||
the Corresponding Source of the work is not available for anyone to copy,
|
||||
free of charge and under the terms of this License, through a publicly
|
||||
available network server or other readily accessible means, then you must
|
||||
either (1) cause the Corresponding Source to be so available, or (2)
|
||||
arrange to deprive yourself of the benefit of the patent license for this
|
||||
particular work, or (3) arrange, in a manner consistent with the
|
||||
requirements of this License, to extend the patent license to downstream
|
||||
recipients. “Knowingly relying” means you have actual knowledge that, but
|
||||
for the patent license, your conveying the covered work in a country, or
|
||||
your recipient's use of the covered work in a country, would infringe
|
||||
one or more identifiable patents in that country that you have reason
|
||||
to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties receiving
|
||||
the covered work authorizing them to use, propagate, modify or convey a
|
||||
specific copy of the covered work, then the patent license you grant is
|
||||
automatically extended to all recipients of the covered work and works
|
||||
based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you are
|
||||
a party to an arrangement with a third party that is in the business of
|
||||
distributing software, under which you make payment to the third party
|
||||
based on the extent of your activity of conveying the work, and under
|
||||
which the third party grants, to any of the parties who would receive the
|
||||
covered work from you, a discriminatory patent license (a) in connection
|
||||
with copies of the covered work conveyed by you (or copies made from
|
||||
those copies), or (b) primarily for and in connection with specific
|
||||
products or compilations that contain the covered work, unless you
|
||||
entered into that arrangement, or that patent license was granted, prior
|
||||
to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any
|
||||
implied license or other defenses to infringement that may otherwise be
|
||||
available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot use,
|
||||
propagate or convey a covered work so as to satisfy simultaneously your
|
||||
obligations under this License and any other pertinent obligations, then
|
||||
as a consequence you may not use, propagate or convey it at all. For
|
||||
example, if you agree to terms that obligate you to collect a royalty for
|
||||
further conveying from those to whom you convey the Program, the only way
|
||||
you could satisfy both those terms and this License would be to refrain
|
||||
entirely from conveying the Program.
|
||||
|
||||
13. Offering the Program as a Service.
|
||||
|
||||
If you make the functionality of the Program or a modified version
|
||||
available to third parties as a service, you must make the Service Source
|
||||
Code available via network download to everyone at no charge, under the
|
||||
terms of this License. Making the functionality of the Program or
|
||||
modified version available to third parties as a service includes,
|
||||
without limitation, enabling third parties to interact with the
|
||||
functionality of the Program or modified version remotely through a
|
||||
computer network, offering a service the value of which entirely or
|
||||
primarily derives from the value of the Program or modified version, or
|
||||
offering a service that accomplishes for users the primary purpose of the
|
||||
Program or modified version.
|
||||
|
||||
“Service Source Code” means the Corresponding Source for the Program or
|
||||
the modified version, and the Corresponding Source for all programs that
|
||||
you use to make the Program or modified version available as a service,
|
||||
including, without limitation, management software, user interfaces,
|
||||
application program interfaces, automation software, monitoring software,
|
||||
backup software, storage software and hosting software, all such that a
|
||||
user could run an instance of the service using the Service Source Code
|
||||
you make available.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
MongoDB, Inc. may publish revised and/or new versions of the Server Side
|
||||
Public License from time to time. Such new versions will be similar in
|
||||
spirit to the present version, but may differ in detail to address new
|
||||
problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the Server Side Public
|
||||
License “or any later version” applies to it, you have the option of
|
||||
following the terms and conditions either of that numbered version or of
|
||||
any later version published by MongoDB, Inc. If the Program does not
|
||||
specify a version number of the Server Side Public License, you may
|
||||
choose any version ever published by MongoDB, Inc.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of
|
||||
the Server Side Public License can be used, that proxy's public statement
|
||||
of acceptance of a version permanently authorizes you to choose that
|
||||
version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions.
|
||||
However, no additional obligations are imposed on any author or copyright holder
|
||||
as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS
|
||||
AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
|
||||
THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
|
||||
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU
|
||||
OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided above
|
||||
cannot be given local legal effect according to their terms, reviewing
|
||||
courts shall apply local law that most closely approximates an absolute
|
||||
waiver of all civil liability in connection with the Program, unless a
|
||||
warranty or assumption of liability accompanies a copy of the Program in
|
||||
return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
109
packages/halkyon_base_server/README.md
Normal file
109
packages/halkyon_base_server/README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# halkyon_base
|
||||
|
||||
What every Halkyon server has, as a Serverpod module (`halkyon_base_server`, with its
|
||||
generated client `halkyon_base_client`): signing in, the account's ways in and its
|
||||
deletion, the pruning of what nobody can return to, and what an instance tells its apps
|
||||
before anyone signs in. It comes from Halkyon Learn's server, whose rules it keeps.
|
||||
|
||||
## What it provides
|
||||
|
||||
Endpoints, under the nickname `base` in a host's client (`client.base.emailCode`):
|
||||
|
||||
| Endpoint | Methods | Who may call |
|
||||
|---|---|---|
|
||||
| `anonymousIdp` | `login` (Serverpod's anonymous sign-in, at most 30 new accounts per IP address and hour) | anyone |
|
||||
| `gitHubUpgrade` | `upgrade`: links a GitHub account to the caller, who becomes `identified` | signed in |
|
||||
| `gitHubSignIn` | `signIn`: a session for whoever has that GitHub account | without a session |
|
||||
| `emailCode` | `sendCode`, `verifyCode`: a code by mail; a new address becomes the caller's, another person's gives a switch token | signed in |
|
||||
| `emailSignIn` | `signIn`: a session for the person a switch token names | without a session |
|
||||
| `account` | `delete`, `signInWays` | signed in |
|
||||
| `instance` | `basics`: GitHub's client ID if any, whether email sign-in is on, the source link, the oldest app build, where data and backups are | anyone |
|
||||
|
||||
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
|
||||
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
|
||||
codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters,
|
||||
valid 15 minutes, 3 tries; at most 3 an hour per address and 10 per network.
|
||||
- Refusals are `GitHubSignInRefusedException` and `EmailSignInRefusedException`, each with a
|
||||
reason.
|
||||
- For the host's own code, `package:halkyon_base_server/base.dart`: the configuration,
|
||||
`tokenManagerBuilders` and `identityProviderBuilders`, the `identified` scope and
|
||||
`grantIdentified`, `deletePerson`, the pruning (`pruneAbandonedAnonymousPeople`,
|
||||
`forgetOldSignUpAttempts`, `forgetExpiredEmailCodes`), short codes, `sourceCodeUrl`,
|
||||
`dataLocation`, `backupLocation`, and the models.
|
||||
|
||||
## Taking it into a host
|
||||
|
||||
1. Depend on both packages, pinned to a signed commit of `halkyon_shared`:
|
||||
|
||||
```yaml
|
||||
# the server's pubspec.yaml
|
||||
halkyon_base_server:
|
||||
git:
|
||||
url: https://code.halkyon.app/project-halkyon/halkyon_shared.git
|
||||
path: packages/halkyon_base_server
|
||||
ref: <signed commit>
|
||||
# the client's pubspec.yaml: the same with halkyon_base_client
|
||||
```
|
||||
|
||||
2. Name it in the server's `config/generator.yaml`:
|
||||
|
||||
```yaml
|
||||
modules:
|
||||
halkyon_base:
|
||||
nickname: base
|
||||
```
|
||||
|
||||
3. Configure it once, before the pod starts, and run its authentication and pruning:
|
||||
|
||||
```dart
|
||||
final pod = Serverpod(args); // the host's generated Serverpod
|
||||
HalkyonBase.configure(
|
||||
pod,
|
||||
HalkyonBase(
|
||||
appName: 'Halkyon Todo', // in sign-in mails
|
||||
upstreamSourceCodeUrl: 'https://code.halkyon.app/project-halkyon/halkyon_todo',
|
||||
minimumAppBuild: 1, // raise it in the commit that breaks older apps
|
||||
deletePersonData: [deleteTodosOf], // the host's own data of a person
|
||||
),
|
||||
);
|
||||
pod.initializeAuthServices(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
);
|
||||
```
|
||||
|
||||
Then schedule the pruning in a future call of the host's own: daily
|
||||
`pruneAbandonedAnonymousPeople`, hourly `forgetOldSignUpAttempts` and
|
||||
`forgetExpiredEmailCodes`.
|
||||
|
||||
4. A host's deletion (`PersonDataDeletion`) runs inside `deletePerson`'s transaction,
|
||||
after the person's sessions ended and before their identities and the person go, in
|
||||
the order given; it deletes the person's rows in the host's tables, and returns what
|
||||
has to wait until the deletion is committed (files outside the database), or null.
|
||||
Pruning deletes through the same function.
|
||||
|
||||
5. Create the host's migration. A host taking the tables over from its own models (as
|
||||
Halkyon Learn does) finds no change once its copies are gone: Serverpod matches tables
|
||||
by name, and these are identical. `create-migration --empty` then records the module's
|
||||
version, with no SQL besides.
|
||||
|
||||
The passwords it reads: `serverSideSessionKeyHashPepper`; for GitHub `githubClientId` and
|
||||
`githubClientSecret`; for email `emailSignInPepper` and `emailSmtpHost`,
|
||||
`emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`. Never change either
|
||||
pepper once people sign in: every session or address hash would be lost. The environment:
|
||||
`SOURCE_URL`, `DATA_LOCATION`, `BACKUP_LOCATION`.
|
||||
|
||||
## Developing it
|
||||
|
||||
`serverpod --no-analytics generate` here after changing a model or an endpoint, and
|
||||
`serverpod --no-analytics create-migration` after changing a table. Tests need no Docker:
|
||||
`dart test` here starts Serverpod's embedded PostgreSQL. They read `config/passwords.yaml`
|
||||
(git-ignored) with throwaway values under `test:` for `database`, `serviceSecret`,
|
||||
`serverSideSessionKeyHashPepper` and `emailSignInPepper`.
|
||||
|
||||
## Licences
|
||||
|
||||
The server package is under the Server Side Public License 1.0, as the Halkyon servers
|
||||
are; the client package under the GNU Affero General Public License 3.0 or later, as the
|
||||
Halkyon apps are. So each combines with its counterpart in a host exactly as the host's
|
||||
own do.
|
||||
22
packages/halkyon_base_server/analysis_options.yaml
Normal file
22
packages/halkyon_base_server/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# The workspace's lint rules (see ../../analysis_options.yaml), minus generated code.
|
||||
include: ../../analysis_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- lib/src/generated/**
|
||||
- test/integration/test_tools/serverpod_test_tools.dart
|
||||
|
||||
# Serverpod's code generator reads this package with an analyzer that doesn't know
|
||||
# Dart v3.13's primary constructors yet, and fails on them in endpoint files ("requires
|
||||
# the 'primary-constructors' language feature"; checked with v4.0.4). So the module
|
||||
# keeps the classic constructor and class-body syntax, as the Halkyon servers do;
|
||||
# switch these back on once Serverpod parses it.
|
||||
linter:
|
||||
rules:
|
||||
empty_container_bodies: false
|
||||
unnecessary_type_name_in_constructor: false
|
||||
use_declaring_parameters: false
|
||||
use_primary_constructors: false
|
||||
|
||||
formatter:
|
||||
trailing_commas: preserve
|
||||
7
packages/halkyon_base_server/config/generator.yaml
Normal file
7
packages/halkyon_base_server/config/generator.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
type: module
|
||||
# The name its endpoints have in a host's client (`client.base.emailCode`);
|
||||
# a host may choose another in its own generator.yaml.
|
||||
nickname: base
|
||||
|
||||
client_package_path: ../halkyon_base_client
|
||||
server_test_tools_path: test/integration/test_tools
|
||||
40
packages/halkyon_base_server/config/test.yaml
Normal file
40
packages/halkyon_base_server/config/test.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# The module's test server: every port 0, so the tests find free ones and can run
|
||||
# side by side. The database is the embedded PostgreSQL Serverpod starts and
|
||||
# manages itself (`dataPath`): no Docker needed, `dart test` is enough. Its password
|
||||
# and the tests' other secrets are in config/passwords.yaml (git-ignored; see the
|
||||
# README for the keys).
|
||||
|
||||
apiServer:
|
||||
port: 0
|
||||
publicHost: localhost
|
||||
publicPort: 0
|
||||
publicScheme: http
|
||||
|
||||
insightsServer:
|
||||
port: 0
|
||||
publicHost: localhost
|
||||
publicPort: 0
|
||||
publicScheme: http
|
||||
|
||||
webServer:
|
||||
port: 0
|
||||
publicHost: localhost
|
||||
publicPort: 0
|
||||
publicScheme: http
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 9090
|
||||
name: halkyon_base_test
|
||||
user: postgres
|
||||
dataPath: .serverpod/test/pgdata
|
||||
|
||||
redis:
|
||||
enabled: false
|
||||
host: localhost
|
||||
port: 9091
|
||||
|
||||
# Session logs are never kept, for privacy, as on every Halkyon server.
|
||||
sessionLogs:
|
||||
persistentEnabled: false
|
||||
consoleEnabled: true
|
||||
2
packages/halkyon_base_server/dart_test.yaml
Normal file
2
packages/halkyon_base_server/dart_test.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
tags:
|
||||
integration: {}
|
||||
18
packages/halkyon_base_server/lib/base.dart
Normal file
18
packages/halkyon_base_server/lib/base.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/// halkyon_base for a host's own code: its configuration
|
||||
/// (`HalkyonBase.configure`), how people authenticate, the account's
|
||||
/// deletion and the pruning, email sign-in's helpers, the instance's
|
||||
/// basics, and the module's models.
|
||||
library;
|
||||
|
||||
export 'src/auth/account.dart';
|
||||
export 'src/auth/auth_config.dart';
|
||||
export 'src/auth/code_mail.dart';
|
||||
export 'src/auth/email.dart';
|
||||
export 'src/auth/github.dart';
|
||||
export 'src/auth/identified.dart';
|
||||
export 'src/auth/scopes.dart';
|
||||
export 'src/common/short_code.dart';
|
||||
export 'src/generated/protocol.dart' hide Protocol;
|
||||
export 'src/halkyon_base.dart';
|
||||
export 'src/instance/data_location.dart';
|
||||
export 'src/instance/source_code.dart';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// What Serverpod's code generator needs to put the module into a host's
|
||||
/// server; not for the host's own code, which imports `base.dart`.
|
||||
library;
|
||||
|
||||
export 'src/generated/endpoints.dart';
|
||||
export 'src/generated/protocol.dart';
|
||||
99
packages/halkyon_base_server/lib/src/auth/account.dart
Normal file
99
packages/halkyon_base_server/lib/src/auth/account.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:halkyon_base_server/src/auth/auth_config.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Deletes [authUserId] with everything they have, in one transaction, in
|
||||
/// this order: their sessions end first, since a device with a valid session
|
||||
/// would upload everything again; then the host deletes their data in its
|
||||
/// own tables ([HalkyonBase.deletePersonData], in the order given); then
|
||||
/// their address's hash and the person with their other identities. Once the
|
||||
/// transaction holds, what the host's deletions left for afterwards runs.
|
||||
Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
||||
final deletions = HalkyonBase.of(session.serverpod).deletePersonData;
|
||||
final afterwards = await session.db.transaction((transaction) async {
|
||||
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
|
||||
await ServerSideSession.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
final afterwards = <AfterDeletion>[];
|
||||
for (final deletion in deletions) {
|
||||
if (await deletion(session, authUserId, transaction) case final after?) {
|
||||
afterwards.add(after);
|
||||
}
|
||||
}
|
||||
// Their address's hash: the way they signed in.
|
||||
// DELETE FROM "email_identity" WHERE "authUserId" = $1
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// Their GitHub id and anonymous account go with them (ON DELETE CASCADE).
|
||||
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
|
||||
await AuthServices.instance.authUsers.delete(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
transaction: transaction,
|
||||
);
|
||||
return afterwards;
|
||||
});
|
||||
for (final after in afterwards) {
|
||||
await after();
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the anonymous people nobody can return to, at most [limit] per
|
||||
/// call, and returns how many: created before and unused since 90 days
|
||||
/// before [now]. Their sessions have expired by then (the inactivity
|
||||
/// timeout, `auth_config.dart`), and an anonymous account can't sign in
|
||||
/// again. Identified people are never pruned. Each goes through
|
||||
/// [deletePerson], so the host's data of theirs goes too.
|
||||
Future<int> pruneAbandonedAnonymousPeople(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
int limit = 500,
|
||||
}) async {
|
||||
// The columns hold UTC as timestamp without time zone.
|
||||
final cutoff = now
|
||||
.toUtc()
|
||||
.subtract(const Duration(days: 90))
|
||||
.toIso8601String()
|
||||
.replaceFirst('Z', '');
|
||||
final abandoned = await session.db.unsafeQuery(
|
||||
'SELECT a."authUserId" FROM "serverpod_auth_idp_anonymous_account" a '
|
||||
'WHERE a."createdAt" < @cutoff::timestamp '
|
||||
'AND NOT EXISTS (SELECT 1 FROM "serverpod_auth_core_session" s '
|
||||
'WHERE s."authUserId" = a."authUserId" '
|
||||
'AND s."lastUsedAt" >= @cutoff::timestamp) '
|
||||
'ORDER BY a."createdAt" LIMIT @limit',
|
||||
parameters: QueryParameters.named({'cutoff': cutoff, 'limit': limit}),
|
||||
);
|
||||
for (final row in abandoned) {
|
||||
await deletePerson(session, UuidValue.withValidation(row.first.toString()));
|
||||
}
|
||||
return abandoned.length;
|
||||
}
|
||||
|
||||
/// Deletes Serverpod's notes of sign-up attempts that are older than the
|
||||
/// rate limit looks back ([anonymousSignUpLimit]) at [now], and returns how
|
||||
/// many: each holds the IP address it came from, which the instance keeps no
|
||||
/// longer than the limit needs it. Serverpod itself deletes them only for its
|
||||
/// own email sign-in, which Halkyon servers don't use. Email sign-in's notes
|
||||
/// go too: none of its limits looks back further.
|
||||
Future<int> forgetOldSignUpAttempts(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
}) async {
|
||||
final cutoff = now.toUtc().subtract(anonymousSignUpLimit.timeframe);
|
||||
// DELETE FROM "serverpod_auth_idp_rate_limited_request_attempt"
|
||||
// WHERE "attemptedAt" < $1
|
||||
final forgotten = await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.attemptedAt < cutoff,
|
||||
);
|
||||
return forgotten.length;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:halkyon_base_server/src/auth/account.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// The signed-in person's account.
|
||||
class AccountEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Deletes the caller with everything they have (see [deletePerson]). The
|
||||
/// app also calls it when an anonymous account is given up (decided
|
||||
/// 2026-09-30). Their other devices learn at once that their sessions
|
||||
/// ended.
|
||||
Future<void> delete(Session session) async {
|
||||
final person = session.authenticated!.authUserId;
|
||||
await deletePerson(session, person);
|
||||
await session.messages.authenticationRevoked(
|
||||
person.uuid,
|
||||
RevokedAuthenticationUser(),
|
||||
);
|
||||
}
|
||||
|
||||
/// How the caller signs in besides their sessions: with GitHub, with an
|
||||
/// email address, both, or neither (anonymous).
|
||||
Future<SignInWays> signInWays(Session session) async {
|
||||
final person = session.authenticated!.authUserId;
|
||||
return SignInWays(
|
||||
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
|
||||
// WHERE "authUserId" = $1
|
||||
gitHub:
|
||||
await GitHubAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0,
|
||||
// SELECT count(*) FROM "email_identity" WHERE "authUserId" = $1
|
||||
email:
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
|
||||
|
||||
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
||||
/// account is rate-limited per IP address (see `auth_config.dart`).
|
||||
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {}
|
||||
39
packages/halkyon_base_server/lib/src/auth/auth_config.dart
Normal file
39
packages/halkyon_base_server/lib/src/auth/auth_config.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:halkyon_base_server/src/auth/github.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
|
||||
import 'package:serverpod_auth_idp_server/providers/github.dart';
|
||||
|
||||
// How people authenticate. The server and its tests use this same
|
||||
// configuration, so the tests check what production runs.
|
||||
|
||||
/// Server-side sessions: revocable immediately, scopes live on the session
|
||||
/// row. No absolute lifetime (an anonymous session cannot be restored). The
|
||||
/// inactivity timeout matches the pruning policy for abandoned accounts.
|
||||
final List<TokenManagerBuilder> tokenManagerBuilders = [
|
||||
ServerSideSessionsConfigFromPasswords(
|
||||
defaultSessionInactivityTimeout: const Duration(days: 90),
|
||||
),
|
||||
];
|
||||
|
||||
/// How many new anonymous accounts one IP address may make, and in what
|
||||
/// time. Serverpod notes each attempt with the address it came from, which
|
||||
/// the instance forgets once the time is up (`forgetOldSignUpAttempts`).
|
||||
const anonymousSignUpLimit = RateLimit(
|
||||
maxAttempts: 30,
|
||||
timeframe: Duration(hours: 1),
|
||||
);
|
||||
|
||||
/// The ways people sign in on [pod].
|
||||
///
|
||||
/// Anonymous accounts are the trial mode for solo use. At most 30 new ones per
|
||||
/// IP address and hour (Serverpod's default is 100): a school class behind one
|
||||
/// address can still start together, while one abuser gets at most 720 a day.
|
||||
///
|
||||
/// GitHub only where the operator registered a GitHub OAuth app (see
|
||||
/// [gitHubSignInAvailable]); its helpers serve our own slim login
|
||||
/// (`github.dart`), and Serverpod's stock GitHub login endpoint isn't exposed.
|
||||
List<IdentityProviderBuilder> identityProviderBuilders(Serverpod pod) => [
|
||||
const AnonymousIdpConfig(perIpAddressRateLimit: anonymousSignUpLimit),
|
||||
if (gitHubSignInAvailable(pod)) GitHubIdpConfigFromPasswords(),
|
||||
];
|
||||
170
packages/halkyon_base_server/lib/src/auth/code_mail.dart
Normal file
170
packages/halkyon_base_server/lib/src/auth/code_mail.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:mailer/mailer.dart' as mailer;
|
||||
import 'package:mailer/smtp_server.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// The mail that carries an email sign-in code (Halkyon Learn's ADR 0010):
|
||||
// plain text, the code in its subject so it shows in the notification and
|
||||
// the system can offer it, no links, no tracking. Sent over SMTP, through any
|
||||
// service the operator chooses (Proton's, for one).
|
||||
|
||||
/// Mails sign-in codes.
|
||||
abstract interface class CodeMailer {
|
||||
/// Mails [code] to [to], worded in [language] (`en` or `de`).
|
||||
Future<void> send(
|
||||
Session session, {
|
||||
required String to,
|
||||
required String code,
|
||||
required String language,
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes codes into the server's log, for development, where nobody has a
|
||||
/// mail service. Never elsewhere: the log would then hold every address
|
||||
/// asked for.
|
||||
final class LogCodeMailer implements CodeMailer {
|
||||
const LogCodeMailer();
|
||||
|
||||
@override
|
||||
Future<void> send(
|
||||
Session session, {
|
||||
required String to,
|
||||
required String code,
|
||||
required String language,
|
||||
}) async => session.log('Email sign-in code for $to: $code');
|
||||
}
|
||||
|
||||
/// A code's mail, worded in [language] (`en`, else German for `de`): its
|
||||
/// subject and its body. [app] is the app's name, which the code is typed
|
||||
/// into, and [instance] the instance's address, which the mail names so
|
||||
/// that people know where they are signing in.
|
||||
({String subject, String body}) codeMail({
|
||||
required String code,
|
||||
required String language,
|
||||
required String app,
|
||||
required String instance,
|
||||
}) => switch (language) {
|
||||
'de' => (
|
||||
// A compound with the name, hyphenated through: Halkyon-Learn-Code.
|
||||
subject: '$code ist dein ${app.replaceAll(' ', '-')}-Code',
|
||||
body:
|
||||
'$code\n'
|
||||
'\n'
|
||||
'Gib diesen Code in $app ein, um dich auf $instance '
|
||||
'anzumelden. Er gilt 15 Minuten.\n'
|
||||
'\n'
|
||||
'Wenn du ihn nicht angefordert hast, ignoriere diese Mail: Ohne den '
|
||||
'Code kann sich niemand mit deiner Adresse anmelden.\n'
|
||||
'\n'
|
||||
'Diese Instanz behält nur einen Fingerabdruck deiner Adresse und '
|
||||
'schreibt ihr nur, wenn du einen Code anforderst.\n',
|
||||
),
|
||||
_ => (
|
||||
subject: '$code is your $app code',
|
||||
body:
|
||||
'$code\n'
|
||||
'\n'
|
||||
'Type this code in $app to sign in on $instance. It is valid '
|
||||
'for 15 minutes.\n'
|
||||
'\n'
|
||||
"If you didn't ask for it, ignore this mail: without the code, nobody "
|
||||
'can sign in with your address.\n'
|
||||
'\n'
|
||||
'This instance keeps only a fingerprint of your address and writes to '
|
||||
'it only when you ask for a code.\n',
|
||||
),
|
||||
};
|
||||
|
||||
/// Mails codes through an SMTP service, over TLS only: directly on port
|
||||
/// 465, upgraded with STARTTLS elsewhere (Proton's: `smtp.protonmail.ch`,
|
||||
/// 587). It logs nothing of a mail, neither its address nor its code.
|
||||
final class SmtpCodeMailer implements CodeMailer {
|
||||
SmtpCodeMailer({
|
||||
required this._server,
|
||||
required this._from,
|
||||
required this._app,
|
||||
required this._instance,
|
||||
});
|
||||
|
||||
final SmtpServer _server;
|
||||
final mailer.Address _from;
|
||||
final String _app;
|
||||
final String _instance;
|
||||
|
||||
/// The mailer [pod]'s passwords set up: `emailSmtpHost`,
|
||||
/// `emailSmtpPort` (587 unless set), `emailSmtpUser`,
|
||||
/// `emailSmtpPassword` and `emailFrom`, the address mails come from (for
|
||||
/// Proton the address the token belongs to), the [app]'s name as their
|
||||
/// sender; null unless all but the port are set. Empty values count as
|
||||
/// unset: Compose passes unset variables on as empty strings.
|
||||
static SmtpCodeMailer? fromPasswords(
|
||||
Serverpod pod, {
|
||||
required String app,
|
||||
}) {
|
||||
String? password(String name) => switch (pod.getPassword(name)) {
|
||||
final value? when value.trim().isNotEmpty => value.trim(),
|
||||
_ => null,
|
||||
};
|
||||
final host = password('emailSmtpHost');
|
||||
final user = password('emailSmtpUser');
|
||||
final secret = password('emailSmtpPassword');
|
||||
final from = password('emailFrom');
|
||||
if (host == null || user == null || secret == null || from == null) {
|
||||
return null;
|
||||
}
|
||||
final port = int.tryParse(password('emailSmtpPort') ?? '') ?? 587;
|
||||
return SmtpCodeMailer(
|
||||
server: SmtpServer(
|
||||
host,
|
||||
port: port,
|
||||
ssl: port == 465,
|
||||
username: user,
|
||||
password: secret,
|
||||
),
|
||||
from: mailer.Address(from, app),
|
||||
app: app,
|
||||
instance: pod.config.apiServer.publicHost,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(
|
||||
Session session, {
|
||||
required String to,
|
||||
required String code,
|
||||
required String language,
|
||||
}) => mail(to: to, code: code, language: language);
|
||||
|
||||
/// Mails [code] to [to], worded in [language]; throws what the service
|
||||
/// answered when it took no mail.
|
||||
Future<void> mail({
|
||||
required String to,
|
||||
required String code,
|
||||
required String language,
|
||||
}) async {
|
||||
final mail = codeMail(
|
||||
code: code,
|
||||
language: language,
|
||||
app: _app,
|
||||
instance: _instance,
|
||||
);
|
||||
await mailer.send(
|
||||
mailer.Message()
|
||||
..from = _from
|
||||
..recipients.add(to)
|
||||
..subject = mail.subject
|
||||
..text = mail.body,
|
||||
_server,
|
||||
timeout: const Duration(seconds: 20),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How codes are mailed on an instance in [runMode]: through [smtp] where
|
||||
/// its operator set one up, into the log in development otherwise, and not
|
||||
/// at all elsewhere (no email sign-in).
|
||||
CodeMailer? codeMailerFor({
|
||||
required String runMode,
|
||||
required CodeMailer? smtp,
|
||||
}) =>
|
||||
smtp ??
|
||||
(runMode == ServerpodRunMode.development ? const LogCodeMailer() : null);
|
||||
477
packages/halkyon_base_server/lib/src/auth/email.dart
Normal file
477
packages/halkyon_base_server/lib/src/auth/email.dart
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/src/auth/code_mail.dart';
|
||||
import 'package:halkyon_base_server/src/auth/identified.dart';
|
||||
import 'package:halkyon_base_server/src/common/short_code.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
// Signing in with an email address, by a code mailed to it each time
|
||||
// (Halkyon Learn's ADR 0010). The instance keeps only a keyed hash of the
|
||||
// address: it can find the person and tell whether an address is free from
|
||||
// what someone types, and it mails only an address someone has just typed.
|
||||
// So the login is our own, on Serverpod's helpers (its code challenges and
|
||||
// rate limiter): Serverpod's email sign-in keeps the address itself, in three
|
||||
// tables, and passwords.
|
||||
|
||||
/// Serverpod's name for email sign-in on session rows.
|
||||
const emailMethod = 'email';
|
||||
|
||||
/// How long a code mailed is valid.
|
||||
const emailCodeLifetime = Duration(minutes: 15);
|
||||
|
||||
/// How this instance mails sign-in codes ([codeMailerFor]): through the
|
||||
/// SMTP service its operator set up, as the app the host names
|
||||
/// ([HalkyonBase.appName]), into the log in development without one, and
|
||||
/// elsewhere not at all. Tests put their own [mailer].
|
||||
final class EmailCodes {
|
||||
EmailCodes._(this.mailer);
|
||||
|
||||
factory EmailCodes.of(Serverpod pod) => _ofPod[pod] ??= EmailCodes._(
|
||||
codeMailerFor(
|
||||
runMode: pod.runMode,
|
||||
smtp: SmtpCodeMailer.fromPasswords(
|
||||
pod,
|
||||
app: HalkyonBase.of(pod).appName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static final _ofPod = Expando<EmailCodes>('Email codes');
|
||||
|
||||
/// What mails the codes; none means no email sign-in here.
|
||||
CodeMailer? mailer;
|
||||
}
|
||||
|
||||
/// The instance's pepper for addresses and codes (`emailSignInPepper` in
|
||||
/// the passwords); null without one. It must never change once people sign
|
||||
/// in with addresses: their hashes would no longer be found.
|
||||
String? _pepperOf(Serverpod pod) => switch (pod.getPassword(
|
||||
'emailSignInPepper',
|
||||
)) {
|
||||
final pepper? when pepper.isNotEmpty => pepper,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Whether people can sign in with an email address here: the instance has
|
||||
/// its pepper and a way to mail codes ([EmailCodes]).
|
||||
bool emailSignInAvailable(Serverpod pod) =>
|
||||
_pepperOf(pod) != null && EmailCodes.of(pod).mailer != null;
|
||||
|
||||
/// [typed] as the address it means, trimmed and in lower case; null when it
|
||||
/// is no address.
|
||||
String? normalizedAddress(String typed) {
|
||||
final address = typed.trim().toLowerCase();
|
||||
return address.length <= 254 && _address.hasMatch(address) ? address : null;
|
||||
}
|
||||
|
||||
/// Something, an @, and a domain with a dot: what mail services take, as
|
||||
/// near as matters; the code mailed proves the rest.
|
||||
final _address = RegExp(r'^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$');
|
||||
|
||||
/// What the instance keeps of the [address] (as [normalizedAddress] gives
|
||||
/// it): HMAC-SHA256 keyed with its pepper, in hex. Without the pepper, a
|
||||
/// database holding it gives no address away.
|
||||
String addressHash(Serverpod pod, String address) {
|
||||
final pepper =
|
||||
_pepperOf(pod) ?? (throw StateError('No emailSignInPepper is set.'));
|
||||
return Hmac(
|
||||
sha256,
|
||||
utf8.encode(pepper),
|
||||
).convert(utf8.encode('email-address:$address')).toString();
|
||||
}
|
||||
|
||||
/// How many codes one address gets in an hour.
|
||||
final _codesPerAddress = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'email_sign_in',
|
||||
source: 'codes_per_address',
|
||||
maxAttempts: 3,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
/// How many codes one network (IP address) asks for in an hour.
|
||||
final _codesPerNetwork = DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'email_sign_in',
|
||||
source: 'codes_per_network',
|
||||
maxAttempts: 10,
|
||||
timeframe: const Duration(hours: 1),
|
||||
),
|
||||
);
|
||||
|
||||
/// Mails a new code to [address] and returns the request it answers, in the
|
||||
/// same way whether anybody has the address or not. An earlier code for the
|
||||
/// address stops being valid. Refused for no address, too many codes, or a
|
||||
/// mail that couldn't be sent, leaving no request behind
|
||||
/// ([EmailSignInRefusedException]).
|
||||
Future<UuidValue> sendSignInCode(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String language,
|
||||
}) async {
|
||||
final pod = session.serverpod;
|
||||
final mailer = EmailCodes.of(pod).mailer;
|
||||
if (mailer == null || !emailSignInAvailable(pod)) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
|
||||
}
|
||||
final to = normalizedAddress(address);
|
||||
if (to == null) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.notAnAddress);
|
||||
}
|
||||
final hash = addressHash(pod, to);
|
||||
// The address first: a code it can't have takes nothing of the network's.
|
||||
if (!await _codesPerAddress.tryRecordAttempt(session, key: hash) ||
|
||||
!await _codesPerNetwork.tryRecordAttempt(
|
||||
session,
|
||||
// As Serverpod's own rate limits take it (no request in tests).
|
||||
key: '${session.request?.connectionInfo.remote.address}',
|
||||
)) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.tooManyCodes);
|
||||
}
|
||||
|
||||
final code = newShortCode();
|
||||
return await session.db.transaction((transaction) async {
|
||||
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN
|
||||
// (SELECT "challengeId" FROM "email_code_request"
|
||||
// WHERE "addressHash" = $1); its request goes with it (ON DELETE
|
||||
// CASCADE)
|
||||
await _forget(
|
||||
session,
|
||||
await EmailCodeRequest.db.find(
|
||||
session,
|
||||
where: (t) => t.addressHash.equals(hash),
|
||||
transaction: transaction,
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
final challenge = await _challengesOf(pod).createChallenge(
|
||||
session,
|
||||
verificationCode: normalizeShortCode(code),
|
||||
transaction: transaction,
|
||||
);
|
||||
// INSERT INTO "email_code_request" ("addressHash", "challengeId",
|
||||
// "createdAt") VALUES ($1, $2, $3)
|
||||
final request = await EmailCodeRequest.db.insertRow(
|
||||
session,
|
||||
EmailCodeRequest(
|
||||
addressHash: hash,
|
||||
challengeId: challenge.id!,
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
try {
|
||||
await mailer.send(
|
||||
session,
|
||||
to: to,
|
||||
code: code,
|
||||
language: language == 'de' ? 'de' : 'en',
|
||||
);
|
||||
} on Exception catch (error) {
|
||||
// Its type only: what the mail service answered may name the address.
|
||||
session.log(
|
||||
'Mailing a sign-in code failed: ${error.runtimeType}',
|
||||
level: LogLevel.warning,
|
||||
);
|
||||
throw EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.mailFailed,
|
||||
);
|
||||
}
|
||||
return request.id!;
|
||||
});
|
||||
}
|
||||
|
||||
/// Accepts [code] for [request] from the person [authUserId]: an address
|
||||
/// nobody has becomes theirs, in one transaction (they become `identified`,
|
||||
/// on their sessions too, and stop being anonymous, keeping everything they
|
||||
/// have); their own changes nothing; another person's changes nothing here
|
||||
/// and gives a token that signs in as that person ([signInWithEmail]).
|
||||
///
|
||||
/// Refused for a wrong code, too many tries, a code no longer valid, or a
|
||||
/// second address for someone who has one ([EmailSignInRefusedException]);
|
||||
/// nothing changes then.
|
||||
Future<EmailCodeAccepted> acceptSignInCode(
|
||||
Session session, {
|
||||
required UuidValue authUserId,
|
||||
required UuidValue request,
|
||||
required String code,
|
||||
}) async {
|
||||
final pod = session.serverpod;
|
||||
if (!emailSignInAvailable(pod)) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
|
||||
}
|
||||
return await session.db.transaction((transaction) async {
|
||||
final switchToken = await _refusingAsEmail(
|
||||
() => _challengesOf(pod).verifyChallenge(
|
||||
session,
|
||||
requestId: request,
|
||||
verificationCode: normalizeShortCode(code),
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
final accepted = (await _requestById(
|
||||
session,
|
||||
request,
|
||||
transaction: transaction,
|
||||
))!;
|
||||
// SELECT * FROM "email_identity"
|
||||
// WHERE "addressHash" = $1 OR "authUserId" = $2
|
||||
final identities = await EmailIdentity.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.addressHash.equals(accepted.addressHash) |
|
||||
t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
final owner = identities
|
||||
.where((identity) => identity.addressHash == accepted.addressHash)
|
||||
.firstOrNull;
|
||||
if (owner != null && owner.authUserId != authUserId) {
|
||||
// Kept until the token is used, or its time is up.
|
||||
return EmailCodeAccepted(switchToken: switchToken);
|
||||
}
|
||||
if (owner == null) {
|
||||
if (identities.isNotEmpty) {
|
||||
throw EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.otherAddressLinked,
|
||||
);
|
||||
}
|
||||
// INSERT INTO "email_identity" ("authUserId", "addressHash",
|
||||
// "createdAt") VALUES ($1, $2, $3)
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: authUserId,
|
||||
addressHash: accepted.addressHash,
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
transaction: transaction,
|
||||
);
|
||||
await grantIdentified(session, authUserId, transaction: transaction);
|
||||
// DELETE FROM "serverpod_auth_idp_anonymous_account"
|
||||
// WHERE "authUserId" = $1
|
||||
await AnonymousAccount.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await _forget(session, [accepted], transaction: transaction);
|
||||
return EmailCodeAccepted();
|
||||
});
|
||||
}
|
||||
|
||||
/// A new session for the person whose address a code proved, from the token
|
||||
/// [acceptSignInCode] gave; once only. Called without a session: Serverpod
|
||||
/// issues no session for another person inside a signed-in call. Refused
|
||||
/// once the token was used or its time is up, or for a token it never gave
|
||||
/// ([EmailSignInRefusal.codeExpired]).
|
||||
Future<AuthSuccess> signInWithEmail(
|
||||
Session session, {
|
||||
required String switchToken,
|
||||
}) async {
|
||||
final pod = session.serverpod;
|
||||
if (!emailSignInAvailable(pod)) {
|
||||
throw EmailSignInRefusedException(reason: EmailSignInRefusal.unavailable);
|
||||
}
|
||||
return await session.db.transaction((transaction) async {
|
||||
final request = await _refusingAsEmail(
|
||||
() => _challengesOf(pod).completeChallenge(
|
||||
session,
|
||||
completionToken: switchToken,
|
||||
transaction: transaction,
|
||||
),
|
||||
);
|
||||
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
|
||||
final identity = await EmailIdentity.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.addressHash.equals(request.addressHash),
|
||||
transaction: transaction,
|
||||
);
|
||||
await _forget(session, [request], transaction: transaction);
|
||||
if (identity == null) {
|
||||
// The person deleted their account since.
|
||||
throw EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.codeExpired,
|
||||
);
|
||||
}
|
||||
final person = await AuthServices.instance.authUsers.get(
|
||||
session,
|
||||
authUserId: identity.authUserId,
|
||||
transaction: transaction,
|
||||
);
|
||||
return await AuthServices.instance.tokenManager.issueToken(
|
||||
session,
|
||||
authUserId: identity.authUserId,
|
||||
method: emailMethod,
|
||||
scopes: person.scopeNames.map(Scope.new).toSet(),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Deletes the code requests past their time at [now], and returns how many:
|
||||
/// hourly, with the rate limit's notes (`PruningFutureCall`).
|
||||
Future<int> forgetExpiredEmailCodes(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
}) async {
|
||||
// SELECT * FROM "email_code_request" WHERE "createdAt" < $1
|
||||
final expired = await EmailCodeRequest.db.find(
|
||||
session,
|
||||
where: (t) => t.createdAt < now.subtract(emailCodeLifetime),
|
||||
);
|
||||
await _forget(session, expired);
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
/// Deletes [requests] with their challenges; deleting a challenge deletes
|
||||
/// its request (`ON DELETE CASCADE`).
|
||||
Future<void> _forget(
|
||||
Session session,
|
||||
List<EmailCodeRequest> requests, {
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final challenges = {
|
||||
for (final request in requests) ...[
|
||||
request.challengeId,
|
||||
?request.switchChallengeId,
|
||||
],
|
||||
};
|
||||
if (challenges.isNotEmpty) {
|
||||
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN (…)
|
||||
await SecretChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.inSet(challenges),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What [action] gives; a challenge's refusal as email sign-in's.
|
||||
Future<T> _refusingAsEmail<T>(Future<T> Function() action) async {
|
||||
try {
|
||||
return await action();
|
||||
} on SecretChallengeException catch (refusal) {
|
||||
throw EmailSignInRefusedException(
|
||||
reason: switch (refusal) {
|
||||
ChallengeInvalidVerificationCodeException() =>
|
||||
EmailSignInRefusal.wrongCode,
|
||||
ChallengeRateLimitExceededException() =>
|
||||
EmailSignInRefusal.tooManyTries,
|
||||
ChallengeRequestNotFoundException() ||
|
||||
ChallengeAlreadyUsedException() ||
|
||||
ChallengeExpiredException() ||
|
||||
ChallengeNotVerifiedException() ||
|
||||
ChallengeInvalidCompletionTokenException() =>
|
||||
EmailSignInRefusal.codeExpired,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The request [id] with its challenges; null when it's gone.
|
||||
Future<EmailCodeRequest?> _requestById(
|
||||
Session session,
|
||||
UuidValue id, {
|
||||
required Transaction? transaction,
|
||||
}) =>
|
||||
// SELECT * FROM "email_code_request" WHERE "id" = $1, with its
|
||||
// challenges
|
||||
EmailCodeRequest.db.findById(
|
||||
session,
|
||||
id,
|
||||
transaction: transaction,
|
||||
include: EmailCodeRequest.include(
|
||||
challenge: SecretChallenge.include(),
|
||||
switchChallenge: SecretChallenge.include(),
|
||||
),
|
||||
);
|
||||
|
||||
bool _isExpired(EmailCodeRequest request) =>
|
||||
request.createdAt.add(emailCodeLifetime).isBefore(clock.now());
|
||||
|
||||
/// The code challenges of [pod]'s email sign-in, with its pepper.
|
||||
SecretChallengeUtil<EmailCodeRequest> _challengesOf(Serverpod pod) =>
|
||||
_challenges[pod] ??= _newChallenges(_pepperOf(pod)!);
|
||||
|
||||
final _challenges = Expando<SecretChallengeUtil<EmailCodeRequest>>(
|
||||
'Email code challenges',
|
||||
);
|
||||
|
||||
SecretChallengeUtil<EmailCodeRequest> _newChallenges(String pepper) {
|
||||
Future<void> forgetOne(Session session, EmailCodeRequest request) =>
|
||||
// Outside the caller's transaction, so it isn't rolled back.
|
||||
_forget(session, [request]);
|
||||
return SecretChallengeUtil(
|
||||
// Codes are short: Argon2id at its usual cost.
|
||||
hashUtil: Argon2HashUtil(hashPepper: pepper, hashSaltLength: 16),
|
||||
// Switch tokens are long and random.
|
||||
completionTokenHash: Argon2HashUtil.forRandomSecrets(
|
||||
hashPepper: pepper,
|
||||
hashSaltLength: 16,
|
||||
),
|
||||
verificationConfig: SecretChallengeVerificationConfig(
|
||||
// Three tries for each code; then it goes, unless it was accepted.
|
||||
rateLimiter: DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'email_sign_in',
|
||||
source: 'code_tries',
|
||||
maxAttempts: 3,
|
||||
onRateLimitExceeded: (session, key) async {
|
||||
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id"
|
||||
// = (the request's) AND the request has no switch challenge
|
||||
final request = await _requestById(
|
||||
session,
|
||||
UuidValue.withValidation(key),
|
||||
transaction: null,
|
||||
);
|
||||
if (request != null && request.switchChallengeId == null) {
|
||||
await forgetOne(session, request);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
getRequest: _requestById,
|
||||
isAlreadyUsed: (request) => request.switchChallengeId != null,
|
||||
getChallenge: (request) => request.challenge!,
|
||||
isExpired: _isExpired,
|
||||
onExpired: forgetOne,
|
||||
linkCompletionToken: (session, request, challenge, {transaction}) async {
|
||||
// UPDATE "email_code_request" SET "switchChallengeId" = $1
|
||||
// WHERE "id" = $2 AND "switchChallengeId" IS NULL
|
||||
final linked = await EmailCodeRequest.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.switchChallengeId(challenge.id)],
|
||||
where: (t) =>
|
||||
t.id.equals(request.id) & t.switchChallengeId.equals(null),
|
||||
transaction: transaction,
|
||||
);
|
||||
if (linked.isEmpty) {
|
||||
throw ChallengeAlreadyUsedException();
|
||||
}
|
||||
},
|
||||
),
|
||||
completionConfig: SecretChallengeCompletionConfig(
|
||||
// Switch tokens are long and random, but tries are counted all the
|
||||
// same.
|
||||
rateLimiter: DatabaseRateLimiter(
|
||||
RateLimiterConfig(
|
||||
domain: 'email_sign_in',
|
||||
source: 'switch_tries',
|
||||
maxAttempts: 3,
|
||||
),
|
||||
),
|
||||
getRequest: _requestById,
|
||||
getCompletionChallenge: (request) => request.switchChallenge,
|
||||
isExpired: _isExpired,
|
||||
onExpired: forgetOne,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
### A code accepted: the address is now the signed-in person's (or already
|
||||
### was), or it is another person's and [switchToken] signs in as them.
|
||||
class: EmailCodeAccepted
|
||||
fields:
|
||||
### Null when the address is the signed-in person's; else what signs in as
|
||||
### the person who has it, once, within the code's 15 minutes
|
||||
### (`EmailSignInEndpoint.signIn`).
|
||||
switchToken: String?
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:halkyon_base_server/src/auth/email.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// Signing in with an email address, by a code mailed to it:
|
||||
/// the way to keep an anonymous account, as linking GitHub is, and a second
|
||||
/// way in for someone who signs in with GitHub. Signed in only, as every
|
||||
/// device is from its first start.
|
||||
class EmailCodeEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Mails a code to [address], worded in [language] (`en`, `de`), and
|
||||
/// returns the request it answers; refusals as in [sendSignInCode].
|
||||
Future<UuidValue> sendCode(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String language,
|
||||
}) => sendSignInCode(session, address: address, language: language);
|
||||
|
||||
/// Accepts [code] for [request]: the address becomes the caller's unless
|
||||
/// it is another person's, whose token then signs in as them
|
||||
/// (`EmailSignInEndpoint.signIn`); refusals as in [acceptSignInCode].
|
||||
Future<EmailCodeAccepted> verifyCode(
|
||||
Session session, {
|
||||
required UuidValue request,
|
||||
required String code,
|
||||
}) => acceptSignInCode(
|
||||
session,
|
||||
authUserId: session.authenticated!.authUserId,
|
||||
request: request,
|
||||
code: code,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
### A code mailed to an address, waiting to be typed (Halkyon Learn's ADR
|
||||
### 0010): the address's hash and the code's challenge (Serverpod's
|
||||
### SecretChallenge, which keeps the code as an Argon2id hash). Once the code
|
||||
### is typed and the address turns out to be another person's, the challenge
|
||||
### of the token that switches to them; otherwise the request goes once used.
|
||||
### Requests last 15 minutes, and those past their time go hourly.
|
||||
class: EmailCodeRequest
|
||||
serverOnly: true
|
||||
table: email_code_request
|
||||
fields:
|
||||
id: UuidValue?, defaultPersist=random_v7
|
||||
addressHash: String
|
||||
challenge: module:serverpod_auth_idp:SecretChallenge?, relation(onDelete=Cascade)
|
||||
switchChallenge: module:serverpod_auth_idp:SecretChallenge?, relation(optional, onDelete=Cascade)
|
||||
createdAt: DateTime
|
||||
indexes:
|
||||
email_code_request_address_idx:
|
||||
fields: addressHash
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
### A person's email address as a way to sign in (Halkyon Learn's ADR
|
||||
### 0010): only a keyed hash of it, never the address. The instance mails a
|
||||
### code only to an address someone has just typed, and can't write to
|
||||
### anyone otherwise.
|
||||
class: EmailIdentity
|
||||
serverOnly: true
|
||||
table: email_identity
|
||||
fields:
|
||||
### The person: the AuthUser's id.
|
||||
authUserId: UuidValue
|
||||
### HMAC-SHA256 of the address, lower-cased, keyed with the instance's
|
||||
### emailSignInPepper, in hex (`addressHash` in email.dart).
|
||||
addressHash: String
|
||||
createdAt: DateTime
|
||||
indexes:
|
||||
### One address per person, and each address one person's.
|
||||
email_identity_person_idx:
|
||||
fields: authUserId
|
||||
unique: true
|
||||
email_identity_address_idx:
|
||||
fields: addressHash
|
||||
unique: true
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue